diff --git a/docs/_static/homepage.gif b/docs/_static/homepage.gif index 4689dd893f..2210d6ae7d 100644 Binary files a/docs/_static/homepage.gif and b/docs/_static/homepage.gif differ diff --git a/docs/user_guide/examples/tutorial_homepage_animation.md b/docs/user_guide/examples/tutorial_homepage_animation.md new file mode 100644 index 0000000000..82f838f136 --- /dev/null +++ b/docs/user_guide/examples/tutorial_homepage_animation.md @@ -0,0 +1,150 @@ +# 🎓 The homepage animation + +This notebook shows how to create the animation on the homepage of the Parcels documentation. It uses a dataset of virtual particles at the surface of the global ocean simulation that can be retrieved from the [Copernicus Marine Service](https://marine.copernicus.eu/). + +```python +import cartopy +import cartopy.crs as ccrs +import matplotlib.gridspec as gridspec +import matplotlib.pyplot as plt +import numpy as np +import polars as pl +import xarray as xr +from matplotlib.animation import FuncAnimation, PillowWriter + +import parcels +``` + +The cell below provides the code needed to run this simulation - but because it is a time-consuming run (~20 minutes) and requires a login on the Copernicus Marine Service, we also provide the output file for download [here](https://github.com/Parcels-code/parcels-data/raw/refs/heads/main/data-parquet/copernicusmarine_globalsurface.parquet). + +```python +particle_filename = "copernicusmarine_globalsurface.parquet" + + +def run_global_copernicusmarine(): + import copernicusmarine + copernicusmarine.login() + + ds = copernicusmarine.open_dataset( + dataset_id="cmems_mod_glo_phy-cur_anfc_0.083deg_P1D-m", + variables=["uo", "vo"], + start_datetime="2024-01-01", + end_datetime="2024-12-31", + minimum_depth=0.5, + maximum_depth=0.5, + service="arco-geo-series", + chunk_size_limit=1, + ) + ds = ds.fillna(0) + + ds_wrap = xr.concat( + [ + ds, + ds.isel(longitude=slice(0, 1)).assign_coords( + longitude=ds.longitude.isel(longitude=slice(0, 1)) + 360 + ), + ], + dim="longitude", + ) + ds = parcels.convert.copernicusmarine_to_sgrid( + fields={"U": ds_wrap["uo"], "V": ds_wrap["vo"]} + ) + fieldset = parcels.FieldSet.from_sgrid_conventions(ds) + fieldset.UV.interp_method = parcels.interpolators.XFreeslip() + fieldset.to_windowed_arrays() + + lon, lat = np.meshgrid(np.arange(-179, 180, 2), np.arange(-89, 90, 2)) + lon = lon.flatten() + lat = lat.flatten() + + # Filter out particles that are not in the ocean (i.e. where speed is zero) + u, v = fieldset.UV[np.zeros_like(lon), np.zeros_like(lat), lat, lon] + speed = np.sqrt(u**2 + v**2) + inocean = speed > 0 + + pset = parcels.ParticleSet(fieldset, x=lon[inocean], y=lat[inocean]) + + oufile = parcels.ParticleFile( + particle_filename, + outputdt=np.timedelta64(5, "D"), + ) + + def AdvectionRK2_periodic(particles, fieldset): # pragma: no cover + """Advection of particles using second-order Runge-Kutta integration, + keeping particles within the periodic domain [-180, 180]. + """ + (u1, v1) = fieldset.UV[particles] + x1 = particles.x + u1 * 0.5 * particles.dt + x1 = ((x1 + 180) % 360) - 180 + y1 = particles.y + v1 * 0.5 * particles.dt + (u2, v2) = fieldset.UV[ + particles.t + 0.5 * particles.dt, particles.z, y1, x1, particles + ] + particles.dx += u2 * particles.dt + particles.dx = ((particles.dx + particles.x + 180) % 360) - (particles.x + 180) + particles.dy += v2 * particles.dt + + pset.execute( + [AdvectionRK2_periodic], + dt=np.timedelta64(1, "h"), + runtime=np.timedelta64(365, "D"), + output_file=oufile, + ) + + +run_global_copernicusmarine() +``` + +The animation consists of two figures: the northern hemisphere and the southern hemisphere, using the [Cartopy package](https://cartopy.readthedocs.io/stable/) for map projections. + +```python +fig = plt.figure(figsize=(8, 4)) +gs = gridspec.GridSpec(ncols=8, nrows=4, figure=fig) + +scat = [None, None] +particles = df.filter(pl.col("t") == pl.lit(plottimes[0])) + +for i, central_latitude in enumerate([90, -90]): + ax = fig.add_subplot( + gs[:, :4] if central_latitude == 90 else gs[:, 4:], + projection=ccrs.NearsidePerspective( + central_latitude=central_latitude, + central_longitude=-30, + satellite_height=15000000, + ), + ) + ax.add_feature(cartopy.feature.LAND, zorder=1) + ax.add_feature(cartopy.feature.OCEAN, zorder=1) + ax.coastlines() + scat[i] = ax.scatter( + particles["x"], + particles["y"], + marker=".", + s=25, + c="#AB2200", + edgecolor="white", + linewidth=0.15, + transform=ccrs.PlateCarree(), + ) + + +def animate(i): + particles = df.filter(pl.col("t") == pl.lit(plottimes[i])) + scat[0].set_offsets(np.c_[particles["x"], particles["y"]]) + scat[1].set_offsets(np.c_[particles["x"], particles["y"]]) + return scat[0], scat[1] + + +plt.rcParams["animation.html"] = "jshtml" +anim = FuncAnimation(fig, animate, frames=len(plottimes), interval=150, blit=True) +plt.close(fig) +anim.save( + particle_filename.replace(".parquet", ".gif"), + writer=PillowWriter(fps=6), + savefig_kwargs={"transparent": True}, +) +``` + +The resulting animation is then + +![gif](../../_static/homepage.gif) diff --git a/docs/user_guide/examples_v3/tutorial_peninsula_AvsCgrid.ipynb b/docs/user_guide/examples/tutorial_peninsula_AvsCgrid.ipynb similarity index 63% rename from docs/user_guide/examples_v3/tutorial_peninsula_AvsCgrid.ipynb rename to docs/user_guide/examples/tutorial_peninsula_AvsCgrid.ipynb index 73c3a57599..b1691e2f66 100644 --- a/docs/user_guide/examples_v3/tutorial_peninsula_AvsCgrid.ipynb +++ b/docs/user_guide/examples/tutorial_peninsula_AvsCgrid.ipynb @@ -4,23 +4,16 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# The impact of grid and velocity interpolation scheme on trajectories" + "# 📖 The sensitivity of grid and velocity interpolation" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The notebook below shows how sensitive trajectories are to the combination of grid and velocity interpolation schemes. We will use the Peninsula example (also discussed in the [Quickstart to Parcels tutorial](https://docs.oceanparcels.org/en/latest/examples/parcels_tutorial.html#Sampling-a-Field-with-Particles)), and carefully compare how trajectories on the [A-grid and C-grid](https://docs.oceanparcels.org/en/latest/examples/documentation_indexing.html) version of this flow differ. To enhance the differences, we deliberately use a very coarse-resolution grid." + "The notebook below shows how sensitive trajectories are to the combination of grid and velocity interpolation schemes. We will use the idealised Peninsula flowfield and carefully compare how trajectories on the [A-grid and C-grid](https://docs.oceanparcels.org/en/latest/examples/documentation_indexing.html) version of this flow differ. To enhance the differences, we deliberately use a very coarse-resolution grid." ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, { "cell_type": "code", "execution_count": null, @@ -33,7 +26,6 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import scipy\n", - "import xarray as xr\n", "from matplotlib.lines import Line2D\n", "\n", "import parcels" @@ -52,38 +44,7 @@ "metadata": {}, "outputs": [], "source": [ - "def peninsula_fieldset(xdim=14, ydim=16, grid_type=\"A\", interp_method=\"linear\"):\n", - " domainsizeX, domainsizeY = (1.0e5, 5.0e4)\n", - " lon = np.linspace(0, domainsizeX, xdim, dtype=np.float32)\n", - " lat = np.linspace(0, domainsizeY, ydim, dtype=np.float32)\n", - "\n", - " u0 = 1\n", - " x0 = domainsizeX / 2\n", - " R = 0.32 * domainsizeX / 2\n", - "\n", - " x, y = np.meshgrid(lon, lat, sparse=True, indexing=\"xy\")\n", - " P = u0 * R**2 * y / ((x - x0) ** 2 + y**2) - u0 * y\n", - " landpoints = P >= 0.0\n", - " P[landpoints] = 0.0\n", - "\n", - " if grid_type == \"A\":\n", - " U = u0 - u0 * R**2 * ((x - x0) ** 2 - y**2) / (((x - x0) ** 2 + y**2) ** 2)\n", - " V = -2 * u0 * R**2 * ((x - x0) * y) / (((x - x0) ** 2 + y**2) ** 2)\n", - " U[landpoints] = 0.0\n", - " V[landpoints] = 0.0\n", - " elif grid_type == \"C\":\n", - " U = np.zeros(P.shape)\n", - " V = np.zeros(P.shape)\n", - " V[:, 1:] = (P[:, 1:] - P[:, :-1]) / (lon[1] - lon[0])\n", - " U[1:, :] = -(P[1:, :] - P[:-1, :]) / (lat[1] - lat[0])\n", - "\n", - " data = {\"U\": U, \"V\": V, \"P\": P}\n", - " dimensions = {\"lon\": lon, \"lat\": lat}\n", - "\n", - " fieldset = parcels.FieldSet.from_data(data, dimensions, mesh=\"flat\")\n", - " fieldset.U.interp_method = interp_method\n", - " fieldset.V.interp_method = interp_method\n", - " return fieldset" + "from parcels._datasets.structured.generated import peninsula_dataset" ] }, { @@ -99,23 +60,23 @@ "metadata": {}, "outputs": [], "source": [ - "def SampleP(particle, fieldset, time):\n", - " particle.p = fieldset.P[time, particle.lon, particle.lat, particle.depth]\n", + "def SampleP(particles, fieldset):\n", + " particles.p = fieldset.P[particles]\n", "\n", "\n", - "def DeleteParticle(particle, fieldset, time):\n", - " if particle.state >= 50:\n", - " particle.delete()\n", + "def DeleteParticle(particles, fieldset):\n", + " any_error = particles.state >= 50 # This captures all Errors\n", + " particles[any_error].state = parcels.StatusCode.Delete\n", "\n", "\n", - "pclass = parcels.Particle.add_variables({\"p\": np.float32})" + "pclass = parcels.Particle.add_variable(parcels.Variable(\"p\", np.float32))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Now we run six different experiments, each with a different combination of grid type (\"A\" or \"C\") and velocity interpolation scheme ([\"linear\"](https://docs.oceanparcels.org/en/latest/examples/tutorial_interpolation.html), [\"freeslip\"](https://docs.oceanparcels.org/en/latest/examples/documentation_unstuck_Agrid.html#3.-Slip-boundary-conditions), [\"cgrid_velocity\"](https://docs.oceanparcels.org/en/latest/examples/documentation_indexing.html) and [\"analytical\"](https://docs.oceanparcels.org/en/latest/examples/tutorial_analyticaladvection.html))." + "Now we run six different experiments, each with a different combination of grid type (\"A\" or \"C\") and velocity interpolation scheme ([\"linear\"](https://docs.oceanparcels.org/en/latest/examples/tutorial_interpolation.html), [\"freeslip\"](https://docs.oceanparcels.org/en/latest/examples/documentation_unstuck_Agrid.html#3.-Slip-boundary-conditions), [\"cgrid_velocity\"](https://docs.oceanparcels.org/en/latest/examples/documentation_indexing.html)) # TODO also add [\"analytical\"](https://docs.oceanparcels.org/en/latest/examples/tutorial_analyticaladvection.html)." ] }, { @@ -129,7 +90,9 @@ " \"\"\"Utility class to consolidate experiment parameters.\"\"\"\n", "\n", " grid_type: Literal[\"A\", \"C\"]\n", - " interp_method: Literal[\"linear\", \"freeslip\", \"cgrid_velocity\", \"analytical\"]\n", + " interp_method: Literal[\n", + " \"linear\", \"freeslip\", \"cgrid_velocity\"\n", + " ] # , \"analytical\"] TODO add analtical when implemented\n", "\n", " @property\n", " def fieldset_interp_method(self):\n", @@ -146,43 +109,44 @@ "\n", " @property\n", " def file_name(self):\n", - " return f\"Trajs_{self.grid_type}_{self.interp_method}.zarr\"\n", + " return f\"Trajs_{self.grid_type}_{self.interp_method}.parquet\"\n", "\n", " @property\n", " def plot_title(self):\n", - " if self.interp_method == \"analytical\":\n", + " if str(self.interp_method) == \"analytical\":\n", " interp_name = \"analytical advection\"\n", " else:\n", - " interp_name = f\"{self.interp_method.replace('_velocity', '')} interpolation\"\n", + " interp_name = f\"{str(self.interp_method).replace('_Velocity', '').replace('(...)', '')} interpolation\"\n", " return f\"{self.grid_type}grid & {interp_name}\"\n", "\n", "\n", "exps = [\n", - " Experiment(\"A\", \"linear\"),\n", - " Experiment(\"A\", \"freeslip\"),\n", - " Experiment(\"C\", \"cgrid_velocity\"),\n", - " Experiment(\"C\", \"analytical\"),\n", - " Experiment(\"A\", \"cgrid_velocity\"),\n", - " Experiment(\"C\", \"linear\"),\n", + " Experiment(\"A\", parcels.interpolators.XLinear_Velocity()),\n", + " Experiment(\"A\", parcels.interpolators.XFreeslip()),\n", + " Experiment(\"C\", parcels.interpolators.CGrid_Velocity()),\n", + " # Experiment(\"C\", \"analytical\"),\n", + " Experiment(\"A\", parcels.interpolators.CGrid_Velocity()),\n", + " Experiment(\"C\", parcels.interpolators.XLinear_Velocity()),\n", "]\n", "\n", - "dt = 1e3 # setting output and execution timestep to same value\n", + "dt = np.timedelta64(1000, \"s\") # setting output and execution timestep to same value\n", "\n", "for exp in exps:\n", - " fieldset = peninsula_fieldset(\n", - " grid_type=exp.grid_type, interp_method=exp.fieldset_interp_method\n", - " )\n", + " ds = peninsula_dataset(xdim=14, ydim=16, grid_type=exp.grid_type)\n", + " fieldset = parcels.FieldSet.from_sgrid_conventions(ds, mesh=\"flat\")\n", + " fieldset.UV.interp_method = exp.fieldset_interp_method\n", "\n", - " pset = parcels.ParticleSet.from_line(\n", - " fieldset, pclass=pclass, size=7, start=(1e3, 1e3), finish=(1e3, 10e3)\n", + " pset = parcels.ParticleSet(\n", + " fieldset, pclass=pclass, x=1e3 * np.ones(7), y=np.linspace(1e3, 10e3, 7)\n", " )\n", - " outfile = pset.ParticleFile(name=exp.file_name, outputdt=dt)\n", + " outfile = parcels.ParticleFile(exp.file_name, outputdt=dt, mode=\"w\")\n", "\n", " pset.execute(\n", " [exp.advection_kernel, SampleP, DeleteParticle],\n", - " endtime=1e5,\n", + " runtime=np.timedelta64(100_000, \"s\"),\n", " dt=dt,\n", " output_file=outfile,\n", + " verbose_progress=False,\n", " )" ] }, @@ -199,16 +163,15 @@ "metadata": {}, "outputs": [], "source": [ - "landmask = np.ma.masked_values(fieldset.U.data[0, :, :], 0)\n", + "ds = peninsula_dataset(xdim=14, ydim=16, grid_type=\"C\")\n", + "landmask = np.ma.masked_values(ds.U.data[:, :], 0)\n", "landmask = landmask.mask.astype(\"int\")\n", "\n", - "lonplot, latplot = np.meshgrid(fieldset.U.lon, fieldset.U.lat)\n", + "lonplot, latplot = np.meshgrid(ds.lon, ds.lat)\n", "\n", - "fl = scipy.interpolate.RectBivariateSpline(\n", - " fieldset.U.lat, fieldset.U.lon, landmask, kx=1, ky=1\n", - ")\n", - "x = fieldset.U.lon[:-1] + np.diff(fieldset.U.lon) / 2\n", - "y = fieldset.U.lat[:-1] + np.diff(fieldset.U.lat) / 2\n", + "fl = scipy.interpolate.RectBivariateSpline(ds.lat, ds.lon, landmask, kx=1, ky=1)\n", + "x = ds.lon[:-1] + np.diff(ds.lon) / 2\n", + "y = ds.lat[:-1] + np.diff(ds.lat) / 2\n", "lon_centers, lat_centers = np.meshgrid(x, y)\n", "l_centers = fl(lat_centers[:, 0], lon_centers[0, :])\n", "lmask = np.ma.masked_values(l_centers, 1)" @@ -231,7 +194,7 @@ "\n", "\n", "def plot_experiment(ax, exp: Experiment) -> None:\n", - " ds = xr.open_zarr(exp.file_name)\n", + " df = parcels.read_particlefile(exp.file_name)\n", " ax.pcolormesh(lonplot, latplot, lmask.mask, cmap=\"Blues\")\n", " ax.scatter(\n", " lonplot,\n", @@ -243,11 +206,12 @@ " vmax=0.05,\n", " edgecolors=\"k\",\n", " )\n", - " ax.plot(ds[\"lon\"].T, ds[\"lat\"].T, linewidth=2)\n", + " for traj in df.partition_by(\"particle_id\", maintain_order=True):\n", + " ax.plot(traj[\"x\"], traj[\"y\"], linewidth=2)\n", " ax.set_title(exp.plot_title)\n", "\n", " # Set the same limits for all subplots\n", - " ax.set_xlim([fieldset.U.lon.min(), fieldset.U.lon.max()])\n", + " ax.set_xlim([lonplot.min(), lonplot.max()])\n", " ax.set_ylim([0, 23e3])\n", " m2km = lambda x, _: f\"{x / 1000:.1f}\"\n", " ax.xaxis.set_major_formatter(m2km)\n", @@ -255,7 +219,7 @@ " ax.set_xlabel(\"x [km]\")\n", "\n", "\n", - "for i, (ax, exp) in enumerate(zip(axs, exps)):\n", + "for i, (ax, exp) in enumerate(zip(axs, exps, strict=True)):\n", " if i == 0:\n", " ax.set_ylabel(\"y [km]\")\n", " plot_experiment(ax, exp)\n", @@ -289,29 +253,25 @@ " vmax=0.05,\n", " edgecolors=\"k\",\n", ")\n", - "ax.contour(lonplot, latplot, fieldset.P.data[0, :, :], colors=\"k\", levels=50)\n", + "ax.contour(lonplot, latplot, ds.P.data, colors=\"k\", levels=50)\n", "\n", "exps_of_interest = filter(\n", " lambda exp: (\n", " exp.plot_title\n", - " in [\"Agrid & linear interpolation\", \"Cgrid & cgrid interpolation\"]\n", + " in [\"Agrid & XLinear interpolation\", \"Cgrid & CGrid interpolation\"]\n", " ),\n", " exps,\n", ")\n", "handles = []\n", "for i, exp in enumerate(exps_of_interest):\n", - " ds = xr.open_zarr(exp.file_name)\n", - " ax.plot(\n", - " ds[\"lon\"].T,\n", - " ds[\"lat\"].T,\n", - " linewidth=2,\n", - " color=f\"C{i:02d}\",\n", - " )\n", + " df = parcels.read_particlefile(exp.file_name)\n", + " for traj in df.partition_by(\"particle_id\", maintain_order=True):\n", + " ax.plot(traj[\"x\"], traj[\"y\"], linewidth=2, color=f\"C{i:02d}\")\n", " # Manually save label for legend\n", " handles.append(Line2D([0], [0], label=exp.plot_title, color=f\"C{i:02d}\"))\n", "\n", "ax.legend(handles=handles)\n", - "ax.set_xlim([fieldset.U.lon.min(), fieldset.U.lon.max()])\n", + "ax.set_xlim([lonplot.min(), lonplot.max()])\n", "ax.set_ylim([0, 23e3])\n", "ax.set_ylabel(\"y [km]\")\n", "ax.set_xlabel(\"x [km]\")\n", @@ -339,16 +299,19 @@ "fig, ax = plt.subplots(1, 1, figsize=(6, 4))\n", "\n", "for i, exp in enumerate(exps):\n", - " ds = xr.open_zarr(exp.file_name)\n", - " ax.plot(\n", - " ds.lat[2:, 0] / 1e3 + i / 10,\n", - " ds.p[2:, -1] - ds.p[2:, 0],\n", - " \"o\",\n", - " alpha=0.7,\n", - " label=exp.plot_title,\n", - " markeredgecolor=\"k\",\n", - " )\n", - "ax.legend(loc=\"lower right\")\n", + " df = parcels.read_particlefile(exp.file_name)\n", + " for j, traj in enumerate(df.partition_by(\"particle_id\", maintain_order=True)):\n", + " label = exp.plot_title if j == 0 else None\n", + " ax.plot(\n", + " traj[\"y\"][0] / 1e3 + i / 10,\n", + " traj[\"p\"][-1] - traj[\"p\"][1],\n", + " \"o\",\n", + " alpha=0.7,\n", + " label=label,\n", + " markeredgecolor=\"k\",\n", + " color=f\"C{i:02d}\",\n", + " )\n", + "ax.legend()\n", "ax.hlines(0, 3, 11, linestyles=\"--\", color=\"k\")\n", "ax.set_xlabel(\"y-location at start [km]\")\n", "ax.set_xlim([3, 11])\n", @@ -367,7 +330,7 @@ ], "metadata": { "kernelspec": { - "display_name": "parcels", + "display_name": "Parcels:docs (3.14.6)", "language": "python", "name": "python3" }, @@ -381,7 +344,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.3" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/docs/user_guide/examples/tutorial_sampling.ipynb b/docs/user_guide/examples/tutorial_sampling.ipynb index 70752e5e86..98336db2e9 100644 --- a/docs/user_guide/examples/tutorial_sampling.ipynb +++ b/docs/user_guide/examples/tutorial_sampling.ipynb @@ -239,7 +239,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Instead, you should use the code `u, v = fieldset.UV[...]`. With this code, the sampling is consistent with the actual velocity fields used in the advection Kernels. The difference is that on a curvilinear grid, `fieldset.U[..]` returns the velocity in the `i`-direction (the columns on the grid), while `fieldset.UV[...]` returns the velocities in the longitude and latitude direction. Furthermore, only `fieldset.UV[...]` sampling can correctly deal with boundary conditions such as `freeslip` and `partialslip` ([documentation_unstuck_Agrid](https://docs.oceanparcels.org/en/latest/examples/documentation_unstuck_Agrid.html#3.-Slip-boundary-conditions))\n" + "Instead, you should use the code `u, v = fieldset.UV[...]`. With this code, the sampling is consistent with the actual velocity fields used in the advection Kernels. The difference is that on a curvilinear grid, `fieldset.U[..]` returns the velocity in the `i`-direction (the columns on the grid), while `fieldset.UV[...]` returns the velocities in the longitude and latitude direction. Furthermore, only `fieldset.UV[...]` sampling can correctly deal with boundary conditions such as `freeslip` and `partialslip` ([tutorial_unstuck_Agrid](tutorial_unstuck_Agrid.ipynb))\n" ] }, { diff --git a/docs/user_guide/examples/tutorial_stuck_particles.ipynb b/docs/user_guide/examples/tutorial_stuck_particles.ipynb new file mode 100644 index 0000000000..28e6f75f06 --- /dev/null +++ b/docs/user_guide/examples/tutorial_stuck_particles.ipynb @@ -0,0 +1,931 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "# 📖 Stuck particles\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "In some Parcels simulations, particles end up moving onto parts of the grid where the velocity field is not defined, such as for example onto land. In this tutorial we look at how this depends on the grid structure.\n", + "\n", + "**Short conclusion: Particles can get stuck on Arakawa A grids and B grids but should not get stuck on C grids.**\n", + "\n", + "This tutorial first looks at how velocity fields are structured and what that means for where the ocean-land boundaries are located. Then we look at how particles may end up getting 'stuck' on these boundaries. How to make these particles become 'unstuck' again in Parcels will be discussed in another notebook.\n", + "\n", + "- [Introduction](#Introduction)\n", + "- [A grid interpolated velocity fields](#1.-A-grids)\n", + "- [B grids](#2.-B-grids)\n", + "- [C grid numerical model - NEMO](#3.-C-grids)\n", + "- [Diffusion](#4.-Diffusion)\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Introduction\n", + "\n", + "Parcels can handle several different types of velocity fields, which makes it widely applicable. This also means that the underlying code and therefore the accuracy of the calculated trajectories can differ, depending on the velocity data input. Even when Parcels runs smoothly with the velocity fields you use, it is good to realize how your velocity fields are structured and how those velocities are generated in the first place.\n", + "\n", + "Horizontal velocity data may be structured on a staggered (Arakawa-C) or unstaggered grid (Arakawa-A). The implementation of these grids is covered in this tutorial - _TODO update link to grid tutorial_.\n", + "\n", + "The source of your velocity data will influence how accurate and physically consistent parcels can calculate trajectories. Common sources are **numerical models and data assimilation products**, **interpolations** of those products or **discrete observations**. The staggering of variables determines how the ocean-land boundaries are defined in models and how their boundary conditions can be satisfied. The Parcels interpolation scheme differs per grid configuration and makes some underlying assumptions that determine the trajectory within a grid cell. Whether the source of your velocity data has physically consistent boundary conditions and how well these align with the assumptions made in Parcels determines how accurately the trajectories move within grid cells. This is especially important near ocean-land boundaries, where particles may end up 'stuck' on land.\n", + "\n", + "Here we will look at two examples of velocity fields in parcels. We visualize the structure of the velocity field, briefly discuss the Parcels implementation and look at how particles get stuck. Then we shortly discuss Arakawa B grids and additional sources of movement that may not respect the ocean-land boundary, such as particle diffusion using a random walk.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true, + "slideshow": { + "slide_type": "skip" + } + }, + "outputs": [], + "source": [ + "from copy import copy\n", + "from datetime import timedelta\n", + "from glob import glob\n", + "\n", + "import matplotlib.gridspec as gridspec\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import xarray as xr\n", + "from matplotlib.colors import ListedColormap\n", + "from matplotlib.lines import Line2D\n", + "from scipy import interpolate\n", + "\n", + "import parcels\n", + "import parcels.tutorial" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "## 1. A grids\n", + "\n", + "Arakawa A grids are unstaggered grids where the velocities $u$, $v$ (and $w$), pressure and other tracers are defined at the same position (on so-called nodes). In numerical models, these nodes can be interpreted to be located **at the corner _or_ at the center of the grid cells**. This means that the cell boundaries, and therefore the solid-fluid boundaries can either be located at the nodes (**figure 1A**) or at 0.5 dx distance from the nodes (**figure 1B**) respectively.\n", + "\n", + "Many ocean models natively run on a C grid, because boundary conditions are easier to implement there (see [C grid](#2.-C-grids)). Sometimes, the C-grid output of these models is interpolated onto an A grid. This is the case for all(?) the data available from [Copernicus Marine Data Store](https://data.marine.copernicus.eu/products), which provide the data on a rectilinear A grid velocity field.\n", + "\n", + "To visualize this, in **figure 1** we show the nodes and cells of a coastal region. The ocean cells and nodes are in red and the land cells and nodes are in white.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds = parcels.tutorial.open_dataset(\n", + " \"CopernicusMarine_data_for_stuck_particles_tutorial/data\"\n", + ")\n", + "\n", + "fields = {\"U\": ds[\"uo\"], \"V\": ds[\"vo\"]}\n", + "ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)\n", + "fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "# Define meshgrid coordinates to plot velocity field with matplotlib pcolormesh\n", + "dlon = ds[\"longitude\"][1] - ds[\"longitude\"][0]\n", + "dlat = ds[\"latitude\"][1] - ds[\"latitude\"][0]\n", + "\n", + "# Outside corner coordinates - coordinates + 0.5 dx\n", + "x_outcorners, y_outcorners = np.meshgrid(\n", + " np.append(\n", + " (ds[\"longitude\"] - 0.5 * dlon),\n", + " (ds[\"longitude\"][-1] + 0.5 * dlon),\n", + " ),\n", + " np.append(\n", + " (ds[\"latitude\"] - 0.5 * dlat),\n", + " (ds[\"latitude\"][-1] + 0.5 * dlat),\n", + " ),\n", + ")\n", + "\n", + "# Inside corner coordinates - coordinates + 0.5 dx\n", + "# needed to plot cells between velocity field nodes\n", + "x_incorners, y_incorners = np.meshgrid(\n", + " (ds[\"longitude\"] + 0.5 * dlon)[:-1],\n", + " (ds[\"latitude\"] + 0.5 * dlat)[:-1],\n", + ")\n", + "\n", + "# Center coordinates\n", + "x_centers, y_centers = np.meshgrid(ds[\"longitude\"], ds[\"latitude\"])\n", + "\n", + "# --------- Velocity fields ---------\n", + "\n", + "# Empty cells between coordinate nodes - essentially on inside corners\n", + "cells = np.zeros((len(ds[\"latitude\"]), len(ds[\"longitude\"])))\n", + "\n", + "# # Masking the flowfield where U = NaN\n", + "umask = np.ma.masked_invalid(ds[\"uo\"][0, 0, :, :])\n", + "\n", + "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", + "u_zeros = ds[\"uo\"][0, 0, :, :].fillna(0)\n", + "# Interpolate U\n", + "fu = interpolate.RectBivariateSpline(\n", + " ds[\"latitude\"],\n", + " ds[\"longitude\"],\n", + " u_zeros,\n", + " kx=1,\n", + " ky=1,\n", + ")\n", + "\n", + "# Velocity field interpolated on the inside corners\n", + "u_corners = fu(y_incorners[:, 0], x_incorners[0, :])\n", + "\n", + "# Masking the interpolated flowfield where U = 0\n", + "udmask = np.ma.masked_values(u_corners, 0)\n", + "\n", + "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", + "v_zeros = ds[\"vo\"][0, 0, :, :].fillna(0)\n", + "\n", + "# Interpolate V\n", + "fv = interpolate.RectBivariateSpline(ds[\"latitude\"], ds[\"longitude\"], v_zeros)\n", + "\n", + "# --------- Plotting domain ---------\n", + "# Select velocity domain to plot\n", + "U = ds[\"uo\"][0, 0, :, :].fillna(0)\n", + "V = ds[\"vo\"][0, 0, :, :].fillna(0)\n", + "\n", + "### Create seethrough colormap to show different grid interpretations\n", + "cmap = plt.get_cmap(\"Blues\")\n", + "my_cmap = cmap(np.arange(cmap.N))\n", + "my_cmap[:, -1] = 0 # set alpha to zero\n", + "my_cmap = ListedColormap(my_cmap)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 3, figsize=(15, 5), constrained_layout=True)\n", + "fig.suptitle(\"Figure 1\", fontsize=16)\n", + "\n", + "titles = [\n", + " \"A) Node at corner cell\\nAgreement with Parcels interpolation\",\n", + " \"B) Node at center cell\\nNot according to Parcels interpolation\",\n", + " \"C) Different boundaries\\nParcels interpolates according to orange grid\",\n", + "]\n", + "\n", + "x_vec = [x_centers, x_outcorners]\n", + "y_vec = [y_centers, y_outcorners]\n", + "mesh = [udmask.mask, umask.mask]\n", + "edgecolors = [\"orange\", \"k\"]\n", + "\n", + "for i, ax in enumerate(axes):\n", + " ax.set_xlabel(\"Longitude\")\n", + " ax.set_ylabel(\"Latitude\")\n", + " ax.set_xlim(x_outcorners[0, 0], x_outcorners[0, -1])\n", + " ax.set_ylim(y_outcorners[0, 0], y_outcorners[-1, 0])\n", + " ax.set_title(titles[i])\n", + " ax.pcolormesh(\n", + " x_vec[i % 2],\n", + " y_vec[i % 2],\n", + " mesh[i % 2],\n", + " cmap=\"Blues_r\",\n", + " edgecolors=edgecolors[i % 2],\n", + " )\n", + " if i == 2:\n", + " ax.pcolormesh(\n", + " x_outcorners,\n", + " y_outcorners,\n", + " cells,\n", + " cmap=my_cmap,\n", + " edgecolors=\"black\",\n", + " )\n", + " ax.scatter(\n", + " x_centers,\n", + " y_centers,\n", + " s=50,\n", + " c=U,\n", + " cmap=\"coolwarm\",\n", + " vmin=-0.05,\n", + " vmax=0.05,\n", + " edgecolors=\"k\",\n", + " )\n", + " ax.quiver(\n", + " x_centers,\n", + " y_centers,\n", + " U,\n", + " V,\n", + " angles=\"xy\",\n", + " scale_units=\"xy\",\n", + " scale=2,\n", + " color=\"w\",\n", + " edgecolor=\"k\",\n", + " )" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Figure 1** shows how _land_ grid points and boundaries can be interpreted, depending on whether you assume a grid node is at the center or corner of a cell. When they are at the corner of a cell, boundaries are interpreted as the edges between two nodes. This is especially visible in the upper center of the figures, where the white nodes with surrounding ocean can be assumed to only have a line of _land_ between them (**figure 1A**), or as entire _land_ cells (**figure 1B**).\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.1 Parcels bilinear interpolation\n", + "\n", + "On Arakawa A grids, Parcels uses a simple bilinear interpolation to find the particle velocity at a specific location in a cell. It finds the four nearest velocity components in a 2D field and interpolates between those. This means that the velocity field is essentially divided into cells by parcels as in **figure 1A**. The boundaries of cells in this case are located between the nodes of the velocity field and therefore the ocean-land boundary lies in between the nodes of the land. Since both velocity components are defined at all four corner nodes, they equally persist in the limit toward the boundary as they go to zero." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.2 How do particles get stuck?\n", + "\n", + "Here we create a parcels simulation of trajectories of particles released near and on the _land_ cells in the velocity field and see how the bilinear interpolation causes particles to get stuck.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": false + }, + "outputs": [], + "source": [ + "npart = 3 # number of particles to be released\n", + "lon = np.linspace(4.95, 5.2, npart)\n", + "lat = np.linspace(52.95, 53.2, npart)\n", + "X, Y = np.meshgrid(lon, lat)\n", + "\n", + "fieldset.models[0].data = fieldset.models[0].data.fillna(\n", + " 0\n", + ") # TODO remove when fillna done in convert.copernicusmarine_to_sgrid\n", + "\n", + "pset = parcels.ParticleSet(fieldset=fieldset, x=X, y=Y)\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"stuck_particles.parquet\",\n", + " outputdt=timedelta(hours=2),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " parcels.kernels.AdvectionRK2,\n", + " runtime=timedelta(days=3),\n", + " dt=timedelta(minutes=12),\n", + " output_file=output_file,\n", + " verbose_progress=False,\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Particles moving toward the boundary will keep slowing down as long as the cross-boundary component of one of the 4 nearest velocity vectors is directed toward the boundary. The distance travelled each `dt` will decrease to unrealistic values in the absence of local forces directing the flow along the boundary. If we define a particle to be stuck when it moves less than approximately 100 meter every hour, we can see how particles get stuck on the model boundary.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "df = parcels.read_particlefile(\"stuck_particles.parquet\")\n", + "\n", + "# Calculating when a particle is stuck\n", + "distance = []\n", + "for traj in df.partition_by(\"particle_id\", maintain_order=True):\n", + " pdx = np.diff(traj[\"x\"], prepend=traj[\"x\"][0])\n", + " pdy = np.diff(traj[\"y\"], prepend=traj[\"y\"][0])\n", + " distance.append(np.sqrt(np.square(pdx) + np.square(pdy)))\n", + "distance = np.array(distance)\n", + "stuck = distance < 1e-3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True)\n", + "fig.suptitle(\"Figure 2. CopernicusMarine A-grid\", fontsize=16)\n", + "gs = gridspec.GridSpec(ncols=2, nrows=1, figure=fig)\n", + "\n", + "titles = [\n", + " \"A) Parcels bilinear interpolation\",\n", + " \"B) Incompatible A grid interpretation\",\n", + "]\n", + "\n", + "x_vec = [x_centers, x_outcorners]\n", + "y_vec = [y_centers, y_outcorners]\n", + "mesh = [udmask.mask, umask.mask]\n", + "edgecolors = [\"orange\", \"k\"]\n", + "\n", + "for i, ax in enumerate(axes):\n", + " ax.set_xlabel(\"Longitude\")\n", + " ax.set_ylabel(\"Latitude\")\n", + " ax.set_xlim(x_outcorners[0, 0], x_outcorners[0, -1])\n", + " ax.set_ylim(y_outcorners[0, 0], y_outcorners[-1, 0])\n", + " ax.set_title(titles[i])\n", + " ax.pcolormesh(\n", + " x_vec[i % 2],\n", + " y_vec[i % 2],\n", + " mesh[i % 2],\n", + " cmap=\"Blues_r\",\n", + " edgecolors=edgecolors[i % 2],\n", + " )\n", + " if i == 2:\n", + " ax.pcolormesh(\n", + " x_outcorners,\n", + " y_outcorners,\n", + " cells,\n", + " cmap=my_cmap,\n", + " edgecolors=\"black\",\n", + " )\n", + " ax.scatter(\n", + " x_centers,\n", + " y_centers,\n", + " s=50,\n", + " c=U,\n", + " cmap=\"coolwarm\",\n", + " vmin=-0.05,\n", + " vmax=0.05,\n", + " edgecolors=\"k\",\n", + " )\n", + " for j, traj in enumerate(df.partition_by(\"particle_id\", maintain_order=True)):\n", + " ax.plot(traj[\"x\"], traj[\"y\"], linewidth=3, zorder=1)\n", + " ax.scatter(traj[\"x\"], traj[\"y\"], c=stuck[j, :], cmap=\"viridis_r\", zorder=2)\n", + "\n", + "color_stuck = copy(plt.get_cmap(\"viridis\"))(0)\n", + "color_moving = copy(plt.get_cmap(\"viridis\"))(256)\n", + "custom_lines = [\n", + " Line2D([0], [0], c=color_moving, marker=\"o\", markersize=10),\n", + " Line2D([0], [0], c=color_stuck, marker=\"o\", markersize=10),\n", + "]\n", + "axes[1].legend(\n", + " custom_lines,\n", + " [\"Moving\", \"Stuck\"],\n", + " bbox_to_anchor=(1.05, 1),\n", + " loc=\"upper left\",\n", + " borderaxespad=0.0,\n", + " framealpha=1,\n", + ")\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In **figure 2A** you can see how particles released in a 3x3 grid keep moving toward the boundary between two _land_ nodes. The ratio of the $u$ and $v$ components stays at a similar value due to both being linearly interpolated to zero at the boundary. Note that the interpretation of nodes at the center of grid cells (**figure 2B**) is clearly incompatible with Parcels interpolation.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. B grids\n", + "\n", + "On Arakawa B grids, $u$ and $v$ are at the same location, while $w$ and scalar variables like pressure are staggered. In 2 dimensional flow, Parcels therefore uses the same bilinear interpolation as for [A grids](#1.-A-grids) to find the horizontal velocity, since the $u$ and $v$ components are similarly collocated. This can cause particles to get stuck in the same way as on A grids.\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "## 3. C grids\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "On staggered grids, different types of boundary conditions can be satisfied simultaneously. On a C-grid, the velocities are defined on the cell-edges normal to the velocity-component and pressure, temperature and tracers are defined at the cell centers. This way, a [Dirichlet boundary condition](https://en.wikipedia.org/wiki/Dirichlet_boundary_condition) can be used for the velocities, while a [Neumann boundary condition](https://en.wikipedia.org/wiki/Neumann_boundary_condition) can be satisfied for the gradient of pressure.\n", + "\n", + "Here we investigate how Parcels interprets the the boundaries on a C grid. For background information, see [Delanmeter & Van Sebille (2019)](https://gmd.copernicus.org/articles/12/3571/2019/). First we show how the velocities are staggered and how the velocity input necessary to create a `FieldSet` results in the definition of boundaries in Parcels. This example uses a NEMO dataset but many of the relevant C grid assumptions are similar in other models, such as MITgcm.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true, + "slideshow": { + "slide_type": "skip" + } + }, + "outputs": [], + "source": [ + "cufields = parcels.tutorial.open_dataset(\"NemoNorthSeaORCA025-N006_data/U\")\n", + "cvfields = parcels.tutorial.open_dataset(\"NemoNorthSeaORCA025-N006_data/V\")\n", + "ds_coords = parcels.tutorial.open_dataset(\"NemoNorthSeaORCA025-N006_data/mesh_mask\")[\n", + " [\"glamf\", \"gphif\"]\n", + "]\n", + "\n", + "xu_corners, yu_corners = np.meshgrid(\n", + " np.arange(cufields[\"x\"].values[0], cufields[\"x\"].values[-1] + 1, 1),\n", + " np.arange(cufields[\"y\"].values[0] - 0.5, cufields[\"y\"].values[-1] + 0.5, 1),\n", + ")\n", + "xv_corners, yv_corners = np.meshgrid(\n", + " np.arange(cvfields[\"x\"].values[0] - 0.5, cvfields[\"x\"].values[-1] + 0.5, 1),\n", + " np.arange(cvfields[\"y\"].values[0], cvfields[\"y\"].values[-1] + 1, 1),\n", + ")\n", + "cx_centers, cy_centers = np.meshgrid(\n", + " np.arange(cvfields[\"x\"].values[0] - 0.5, cvfields[\"x\"].values[-1] + 1.5, 1),\n", + " np.arange(cvfields[\"y\"].values[0] - 0.5, cvfields[\"y\"].values[-1] + 1.5, 1),\n", + ")\n", + "fx_corners, fy_corners = np.meshgrid(\n", + " np.arange(cufields[\"x\"].values[0] - 1, cufields[\"x\"].values[-1] + 1, 1),\n", + " np.arange(cufields[\"y\"].values[0] - 1, cufields[\"y\"].values[-1] + 1, 1),\n", + ")\n", + "c_cells = np.zeros((len(cufields[\"y\"]), len(cufields[\"x\"])))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", + "cu_zeros = np.nan_to_num(cufields[\"uos\"][0])\n", + "f = interpolate.RectBivariateSpline(\n", + " yu_corners[:, 0], xu_corners[0, :], cu_zeros, kx=1, ky=1\n", + ")\n", + "\n", + "# Velocity field interpolated on the T-points - center\n", + "cu_centers = f(cy_centers[:-1, 0], cx_centers[0, :-1])\n", + "\n", + "# Masking the interpolated flowfield where U = 0\n", + "cudmask = np.ma.masked_values(cu_centers, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true, + "slideshow": { + "slide_type": "subslide" + } + }, + "outputs": [], + "source": [ + "fig = plt.figure(figsize=(8, 6))\n", + "fig.suptitle(\"Figure 3 - C grid structure\", fontsize=16)\n", + "ax1 = plt.axes()\n", + "\n", + "ax1.set_xlim(105, 112)\n", + "ax1.set_ylim(50, 54)\n", + "ax1.pcolormesh(\n", + " fx_corners, fy_corners, cudmask, cmap=\"Blues\", edgecolors=\"k\", linewidth=1\n", + ")\n", + "ax1.scatter(\n", + " xu_corners,\n", + " yu_corners,\n", + " s=80,\n", + " c=cufields[\"uos\"][0],\n", + " cmap=\"seismic\",\n", + " vmin=-0.1,\n", + " vmax=0.1,\n", + " edgecolor=\"k\",\n", + " label=\"U\",\n", + ")\n", + "ax1.scatter(\n", + " xv_corners,\n", + " yv_corners,\n", + " s=80,\n", + " c=cvfields[\"vos\"][0],\n", + " cmap=\"PRGn\",\n", + " vmin=-0.1,\n", + " vmax=0.1,\n", + " edgecolor=\"k\",\n", + " label=\"V\",\n", + ")\n", + "ax1.scatter(cx_centers, cy_centers, s=80, c=\"orange\", edgecolor=\"k\", label=\"T\")\n", + "ax1.quiver(\n", + " xu_corners,\n", + " yu_corners,\n", + " cufields[\"uos\"][0],\n", + " np.zeros(xu_corners.shape),\n", + " angles=\"xy\",\n", + " scale_units=\"xy\",\n", + " scale=0.1,\n", + " width=0.007,\n", + ")\n", + "ax1.quiver(\n", + " xv_corners,\n", + " yv_corners,\n", + " np.zeros(xv_corners.shape),\n", + " cvfields[\"vos\"][0],\n", + " angles=\"xy\",\n", + " scale_units=\"xy\",\n", + " scale=0.3,\n", + " width=0.007,\n", + ")\n", + "\n", + "custom_lines = [\n", + " Line2D([0], [0], marker=\"o\", color=\"r\", lw=0),\n", + " Line2D([0], [0], marker=\"o\", color=\"g\", lw=0),\n", + " Line2D([0], [0], marker=\"o\", color=\"orange\", lw=0),\n", + "]\n", + "\n", + "ax1.legend(\n", + " custom_lines,\n", + " [\"U\", \"V\", \"T\"],\n", + " bbox_to_anchor=(1.05, 1),\n", + " loc=\"upper left\",\n", + " borderaxespad=0.0,\n", + ");" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In **figure 3** you can see how a boundary can be traced along the cell edges, where the velocity is zero, because the _normal_ velocities are defined at the edges in a C-grid. This ensures that the most important boundary condition in many models is satisfied: the normal-component of velocity is zero at the boundary. Within a cell, Parcels interpolates the velocities on a C-grid linearly in the direction of that component and constant throughout a cell in the other directions. This means that the cross-boundary component is linearly interpolated to zero at the boundary and the along-boundary component is constant towards the boundary. Note that this is the same interpolation as used in the 'Analytical Advection' Scheme of e.g. TRACMASS and Ariane. _TODO add link to Analytical Advection Scheme._\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3.1 Consistency with NEMO configuration\n", + "\n", + "Here we look at how the lateral boundary conditions are implemented in NEMO. This is documented [here](https://www.nemo-ocean.eu/doc/node58.html).\n", + "\n", + "The cross-boundary component of velocity, defined at the cell faces where the boundary is defined, is set to zero at all boundaries. See the figure below. This corresponds exactly with the velocity field in a Parcels `FieldSet` shown in **figure 3**.\n", + "\n", + "\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are different options for the along-boundary velocity in NEMO: a free-slip condition, a partial-slip condition and a no-slip condition. Since the tangential velocity is not defined at the boundary, this boundary condition is defined by a Neumann boundary condition: the normal derivative of the along-boundary velocity is specified. This derivative is schematically represented by a \"ghost\" velocity on the adjacent land node. The specified derivative is equivalent to what would result from the central difference between the along-boundary velocity at the nearest ocean cell and this \"ghost\" velocity. The type of boundary condition defines the direction and magnitude of this \"ghost\" velocity relative to the along-boundary velocity in the fluid domain. In Parcels, these \"ghost\" velocities may be used to determine how the velocity should be interpolated near the coast. By default, parcels interpolates piecewise-constant in the direction normal to the velocity component. This means that the along-boundary velocity is the same for any distance away from the boundary and therefore equivalent to the free-slip boundary condition shown in subfigure **(a)** below.\n", + "\n", + "\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3.2 Particle trajectories near the boundary in a C grid\n", + "\n", + "Now let's see how particles move near the boundary in a `FieldSet` derived from the mass-conserving NEMO C grid.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "ds_fset = parcels.convert.nemo_to_sgrid(\n", + " fields={\"U\": cufields[\"uo\"], \"V\": cvfields[\"vo\"]},\n", + " coords=ds_coords,\n", + ")\n", + "fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)\n", + "fieldset = fieldset.to_windowed_arrays()\n", + "\n", + "npart = 10 # number of particles to be released\n", + "lon = np.linspace(3, 4, npart)\n", + "lat = 51.5 * np.ones(npart)\n", + "\n", + "pset = parcels.ParticleSet(fieldset=fieldset, x=lon, y=lat)\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"Cgrid-stuck.parquet\",\n", + " outputdt=timedelta(hours=1),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " parcels.kernels.AdvectionRK2,\n", + " runtime=timedelta(days=10),\n", + " dt=timedelta(minutes=5),\n", + " output_file=output_file,\n", + " verbose_progress=False,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "df_C = parcels.read_particlefile(\"Cgrid-stuck.parquet\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "distance_C = []\n", + "for traj in df_C.partition_by(\"particle_id\", maintain_order=True):\n", + " pdx = np.diff(traj[\"x\"], prepend=traj[\"x\"][0])\n", + " pdy = np.diff(traj[\"y\"], prepend=traj[\"y\"][0])\n", + " distance_C.append(np.sqrt(np.square(pdx) + np.square(pdy)))\n", + "distance_C = np.array(distance_C)\n", + "stuck_C = distance_C < 1e-3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(14, 6), constrained_layout=True)\n", + "fig.suptitle(\n", + " \"Figure 4. - NEMO model movement along solid cells in a C grid\", fontsize=16\n", + ")\n", + "\n", + "xlim = ((-5, 5), (2.9, 4.1))\n", + "ylim = ((48, 58), (51.4, 52.2))\n", + "titles = [\"North Sea domain\", \"North Sea domain - zoomed in\"]\n", + "edgecolors = [None, \"k\"]\n", + "for i, ax in enumerate(axes):\n", + " ax.set_ylabel(\"Latitude [degrees]\")\n", + " ax.set_xlabel(\"Longitude [degrees]\")\n", + " ax.set_title(titles[i])\n", + " ax.set_xlim(xlim[i])\n", + " ax.set_ylim(ylim[i])\n", + "\n", + " ax.pcolormesh(\n", + " ds_coords[\"glamf\"],\n", + " ds_coords[\"gphif\"],\n", + " cvfields[\"vo\"][0, 0, 1:, 1:],\n", + " cmap=\"RdBu\",\n", + " vmin=-0.1,\n", + " vmax=0.1,\n", + " edgecolors=edgecolors[i],\n", + " )\n", + " for i, traj in enumerate(df_C.partition_by(\"particle_id\", maintain_order=True)):\n", + " ax.plot(traj[\"x\"], traj[\"y\"], linewidth=3, zorder=1)\n", + " ax.scatter(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " c=stuck_C[i, :],\n", + " cmap=\"viridis_r\",\n", + " zorder=2,\n", + " vmin=0,\n", + " vmax=1,\n", + " )\n", + "\n", + "color_stuck = copy(plt.get_cmap(\"viridis\"))(0)\n", + "color_moving = copy(plt.get_cmap(\"viridis\"))(256)\n", + "custom_lines = [\n", + " Line2D([0], [0], c=color_moving, marker=\"o\", markersize=10),\n", + " Line2D([0], [0], c=color_stuck, marker=\"o\", markersize=10),\n", + "]\n", + "axes[1].legend(\n", + " custom_lines,\n", + " [\"Moving\", \"Stuck\"],\n", + " bbox_to_anchor=(1.05, 1),\n", + " loc=\"upper left\",\n", + " borderaxespad=0.0,\n", + " framealpha=1,\n", + ")\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As we can see in **figure 4**, the particles that start in the ocean move along the boundary, since the cross-boundary component goes to zero, but the along-boundary component is equal to the value away from the coast.\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Diffusion\n", + "\n", + "In many parcels simulations other motions are added. Examples are Brownian motion from a diffusivity field and Stokes drift based on a wind field. These sources of motion will not necessarily consider the same boundaries and, unless they are also based on a C grid, they may also result in particles getting stuck on land. To show how this happens, let us add a random walk to the advection in a C grid velocity field.\n", + "\n", + "This random walk can be added using a diffusion kernel, as documented in [this notebook](tutorial_diffusion.ipynb). Since the particles will move randomly through the domain, without awareness of the solid-fluid boundaries in the velocity field, we cannot define stuck particles as having moved less than a tolerance value and we will instead check whether particles find themselves on land or not. To do this, we sample the local velocities.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "LandParticle = parcels.Particle.add_variable(parcels.Variable(\"on_land\"))\n", + "\n", + "\n", + "def SampleLand(particles, fieldset):\n", + " u, v = fieldset.UV[particles]\n", + " particles.on_land = (u == 0) & (v == 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)\n", + "fieldset = fieldset.to_windowed_arrays()\n", + "\n", + "fieldset.add_constant_field(\"Kh_zonal\", 5, mesh=\"spherical\")\n", + "fieldset.add_constant_field(\"Kh_meridional\", 5, mesh=\"spherical\")\n", + "fieldset.add_context(\"dres\", 0.083)\n", + "\n", + "npart = 10 # number of particles to be released\n", + "lon = np.linspace(3, 4, npart)\n", + "lat = 51.5 * np.ones(npart)\n", + "\n", + "pset = parcels.ParticleSet(fieldset=fieldset, pclass=LandParticle, x=lon, y=lat)\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"Cgrid-diffusion.parquet\",\n", + " outputdt=timedelta(hours=1),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " [parcels.kernels.AdvectionDiffusionEM, SampleLand],\n", + " runtime=timedelta(days=10),\n", + " dt=timedelta(minutes=5),\n", + " output_file=output_file,\n", + " verbose_progress=False,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "df_C_diff = parcels.read_particlefile(\"Cgrid-diffusion.parquet\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "hide_input": true + }, + "outputs": [], + "source": [ + "fig = plt.figure(figsize=(14, 8))\n", + "fig.suptitle(\"Figure 5. Diffusion added to C grid advection\", fontsize=16)\n", + "ax = plt.axes()\n", + "\n", + "ax.set_ylabel(\"Latitude [degrees]\")\n", + "ax.set_xlabel(\"Longitude [degrees]\")\n", + "ax.set_title(\"Particle getting stuck\")\n", + "ax.set_xlim(2.9, 4.1)\n", + "ax.set_ylim(51.4, 52.2)\n", + "\n", + "ax.pcolormesh(\n", + " ds_coords[\"glamf\"],\n", + " ds_coords[\"gphif\"],\n", + " cvfields[\"vo\"][0, 0, 1:, 1:],\n", + " cmap=\"RdBu\",\n", + " vmin=-0.1,\n", + " vmax=0.1,\n", + " edgecolors=\"k\",\n", + " linewidth=1,\n", + ")\n", + "for traj in df_C_diff.partition_by(\"particle_id\", maintain_order=True):\n", + " ax.plot(traj[\"x\"], traj[\"y\"], linewidth=3, zorder=1)\n", + " ax.scatter(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " c=traj[\"on_land\"],\n", + " cmap=\"viridis_r\",\n", + " zorder=2,\n", + " vmin=0,\n", + " vmax=1,\n", + " )\n", + "\n", + "color_stuck = copy(plt.get_cmap(\"viridis\"))(0)\n", + "color_moving = copy(plt.get_cmap(\"viridis\"))(256)\n", + "\n", + "custom_lines = [\n", + " Line2D([0], [0], c=color_stuck, marker=\"o\", markersize=10, lw=0),\n", + " Line2D([0], [0], c=color_moving, marker=\"o\", markersize=10, lw=0),\n", + "]\n", + "ax.legend(\n", + " custom_lines,\n", + " [\"On land\", \"In ocean\"],\n", + " bbox_to_anchor=(1.05, 1),\n", + " loc=\"upper left\",\n", + " borderaxespad=0.0,\n", + " framealpha=1,\n", + ")\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Figure 5** shows why you should consider additional boundary conditions for each component of the motion added to the particle.\n" + ] + } + ], + "metadata": { + "celltoolbar": "Metagegevens bewerken", + "kernelspec": { + "display_name": "Parcels:docs (3.14.6)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/examples/tutorial_unstuck_Agrid.ipynb b/docs/user_guide/examples/tutorial_unstuck_Agrid.ipynb new file mode 100644 index 0000000000..89b8cefcc2 --- /dev/null +++ b/docs/user_guide/examples/tutorial_unstuck_Agrid.ipynb @@ -0,0 +1,1141 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📖 Preventing stuck particles\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In [another notebook](tutorial_stuck_particles.ipynb), we have shown how particles may end up getting stuck on land, especially in A gridded velocity fields. Here we show how you can work around this problem and how large the effects of the solutions on the trajectories are.\n", + "\n", + "Common solutions are:\n", + "\n", + "1. [Delete the particles](#1.-Particle-deletion)\n", + "2. [Displace the particles when they are within a certain distance of the coast.](#2.-Displacement)\n", + "3. [Implement free-slip or partial-slip boundary conditions](#3.-Slip-boundary-conditions)\n", + "\n", + "In the first two of these solutions, kernels are used to modify the trajectories near the coast. The kernels all consist of two parts:\n", + "\n", + "1. Flag particles whose trajectory should be modified\n", + "2. Modify the trajectory accordingly\n", + "\n", + "In the third solution, the interpolation method is changed; this has to be done when creating the `FieldSet`.\n", + "\n", + "This notebook is mainly focused on comparing the different modifications to the trajectory. The flagging of particles is also very relevant however and further discussion on this is encouraged. Some options shown here are:\n", + "\n", + "1. Flag particles within a specific distance to the shore\n", + "2. Flag particles in any gridcell that has a shore edge\n", + "\n", + "As argued in the [previous notebook](tutorial_stuck_particles.ipynb), it is important to accurately plot the grid discretization, in order to understand the motion of particles near the boundary. The velocity fields can best be depicted using points or arrows that define the velocity at a single position. Four of these nodes then form gridcells that can be shown using tiles, for example with `matplotlib.pyplot.pcolormesh`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import timedelta\n", + "\n", + "import matplotlib.gridspec as gridspec\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import xarray as xr\n", + "from scipy import interpolate\n", + "\n", + "import parcels\n", + "import parcels.tutorial" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Particle deletion\n", + "\n", + "The simplest way to avoid trajectories that interact with the coastline is to remove them entirely. You can do this for example by a Kernel that samples the velocity field at the particle position and checks whether the particle is on land or not. If it is, the particle is deleted. This is shown in the following example.\n", + "\n", + "```python\n", + "def DeleteOnLand(particles, fieldset):\n", + " u, v = fieldset.UV[particles]\n", + " on_land = (u == 0) & (v == 0)\n", + " particles[on_land].state = parcels.StatusCode.Delete\n", + "```\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Displacement\n", + "\n", + "A simple concept to avoid particles moving onto shore is displacing them towards the ocean as they get close to shore. This is for example done in [Kaandorp _et al._ (2020)](https://pubs.acs.org/doi/10.1021/acs.est.0c01984) and [Delandmeter and van Sebille (2018)](https://gmd.copernicus.org/articles/12/3571/2019/). To do so, a particle must be 'aware' of where the shore is and displaced accordingly. In Parcels, we can do this by adding a 'displacement' `Field` to the `Fieldset`, which contains vectors pointing away from shore.\n", + "\n", + "### Step 1 Import a velocity field - the A gridded CopernicusMarine velocity field\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds = parcels.tutorial.open_dataset(\n", + " \"CopernicusMarine_data_for_stuck_particles_tutorial/data\"\n", + ")\n", + "\n", + "fields = {\"U\": ds[\"uo\"], \"V\": ds[\"vo\"]}\n", + "ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)\n", + "ds_fset = ds_fset.fillna(\n", + " 0\n", + ") # TODO remove when fillna done in convert.copernicusmarine_to_sgrid\n", + "\n", + "fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)\n", + "fieldset = fieldset.to_windowed_arrays()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Define meshgrid coordinates to plot velocity field with matplotlib pcolormesh\n", + "dlon = ds[\"longitude\"][1] - ds[\"longitude\"][0]\n", + "dlat = ds[\"latitude\"][1] - ds[\"latitude\"][0]\n", + "\n", + "# Outside corner coordinates - coordinates + 0.5 dx\n", + "x_outcorners, y_outcorners = np.meshgrid(\n", + " np.append(\n", + " (ds[\"longitude\"] - 0.5 * dlon),\n", + " (ds[\"longitude\"][-1] + 0.5 * dlon),\n", + " ),\n", + " np.append(\n", + " (ds[\"latitude\"] - 0.5 * dlat),\n", + " (ds[\"latitude\"][-1] + 0.5 * dlat),\n", + " ),\n", + ")\n", + "\n", + "# Inside corner coordinates - coordinates + 0.5 dx\n", + "# needed to plot cells between velocity field nodes\n", + "x_incorners, y_incorners = np.meshgrid(\n", + " (ds[\"longitude\"] + 0.5 * dlon)[:-1],\n", + " (ds[\"latitude\"] + 0.5 * dlat)[:-1],\n", + ")\n", + "\n", + "# Center coordinates\n", + "x_centers, y_centers = np.meshgrid(ds[\"longitude\"], ds[\"latitude\"])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 2: Make a landmask where `land = 1` and `ocean = 0`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Interpolate the landmask to the cell centers\n", + "# only cells with 4 neighboring land points will be land\n", + "landmask = np.ma.masked_invalid(ds[\"uo\"][0, 0, :, :], 0)\n", + "landmask[landmask != 0] = 1\n", + "\n", + "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", + "u_zeros = ds[\"uo\"][0, 0, :, :].fillna(0)\n", + "fl = interpolate.RectBivariateSpline(\n", + " ds[\"latitude\"], ds[\"longitude\"], u_zeros, kx=1, ky=1\n", + ")\n", + "\n", + "l_corners = fl(y_incorners[:, 0], x_incorners[0, :])\n", + "\n", + "# land when interpolated value == 1\n", + "lmask = np.ma.masked_values(l_corners, 0)\n", + "lmask[lmask != 0] = 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True)\n", + "fig.suptitle(\"Figure 1\", fontsize=16)\n", + "\n", + "titles = [\n", + " \"A) Node at corner cell\\nAgreement with Parcels interpolation\",\n", + " \"B) Node at center cell\\nNot according to Parcels interpolation\",\n", + "]\n", + "\n", + "x_vec = [x_centers, x_outcorners]\n", + "y_vec = [y_centers, y_outcorners]\n", + "mesh = [lmask, landmask]\n", + "edgecolors = [\"orange\", \"k\"]\n", + "\n", + "for i, ax in enumerate(axes):\n", + " ax.set_xlabel(\"Longitude\")\n", + " ax.set_ylabel(\"Latitude\")\n", + " ax.set_xlim(x_incorners[0, 0], x_incorners[0, -1])\n", + " ax.set_ylim(y_incorners[0, 0], y_incorners[-1, 0])\n", + " ax.set_title(titles[i])\n", + " ax.pcolormesh(\n", + " x_vec[i % 2],\n", + " y_vec[i % 2],\n", + " mesh[i % 2],\n", + " cmap=\"Blues_r\",\n", + " edgecolors=edgecolors[i % 2],\n", + " )\n", + " ax.scatter(\n", + " x_centers,\n", + " y_centers,\n", + " c=landmask.mask.astype(int),\n", + " s=50,\n", + " cmap=\"coolwarm\",\n", + " vmin=-1,\n", + " vmax=1,\n", + " edgecolors=\"k\",\n", + " )" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Figure 1 shows why it is important to be precise when visualizing the model land and ocean. Parcels trajectories should not cross the land boundary between two land nodes as seen in 1B.\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 3: Detect the coast\n", + "\n", + "We can detect the edges between land and ocean nodes by computing the Laplacian with the 4 nearest neighbors `[i+1,j]`, `[i-1,j]`, `[i,j+1]` and `[i,j-1]`:\n", + "\n", + "$$\\nabla^2 \\text{landmask} = \\partial_{xx} \\text{landmask} + \\partial_{yy} \\text{landmask},$$\n", + "\n", + "and filtering the positive and negative values. This gives us the location of _coast_ nodes (ocean nodes next to land) and _shore_ nodes (land nodes next to the ocean).\n", + "\n", + "Additionally, we can find the nodes that border the coast/shore diagonally by considering the 8 nearest neighbors, including `[i+1,j+1]`, `[i-1,j+1]`, `[i-1,j+1]` and `[i-1,j-1]`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def get_coastal_nodes(landmask):\n", + " \"\"\"Function that detects the coastal nodes, i.e. the ocean nodes directly\n", + " next to land. Computes the Laplacian of landmask.\n", + "\n", + " - landmask: the land mask where land cell = 1 and ocean cell = 0.\n", + "\n", + " Output: 2D array array containing the coastal nodes, the coastal nodes are\n", + " equal to one, and the rest is zero.\n", + " \"\"\"\n", + " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", + " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", + " mask_lap -= 4 * landmask\n", + " coastal = np.ma.masked_array(landmask, mask_lap > 0)\n", + " coastal = coastal.mask.astype(\"int\")\n", + "\n", + " return coastal\n", + "\n", + "\n", + "def get_shore_nodes(landmask):\n", + " \"\"\"Function that detects the shore nodes, i.e. the land nodes directly\n", + " next to the ocean. Computes the Laplacian of landmask.\n", + "\n", + " - landmask: the land mask where land cell = 1 and ocean cell = 0.\n", + "\n", + " Output: 2D array array containing the shore nodes, the shore nodes are\n", + " equal to one, and the rest is zero.\n", + " \"\"\"\n", + " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", + " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", + " mask_lap -= 4 * landmask\n", + " shore = np.ma.masked_array(landmask, mask_lap < 0)\n", + " shore = shore.mask.astype(\"int\")\n", + "\n", + " return shore" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def get_coastal_nodes_diagonal(landmask):\n", + " \"\"\"Function that detects the coastal nodes, i.e. the ocean nodes where\n", + " one of the 8 nearest nodes is land. Computes the Laplacian of landmask\n", + " and the Laplacian of the 45 degree rotated landmask.\n", + "\n", + " - landmask: the land mask built using `make_landmask`, where land cell = 1\n", + " and ocean cell = 0.\n", + "\n", + " Output: 2D array array containing the coastal nodes, the coastal nodes are\n", + " equal to one, and the rest is zero.\n", + " \"\"\"\n", + " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", + " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", + " mask_lap += np.roll(landmask, (-1, 1), axis=(0, 1)) + np.roll(\n", + " landmask, (1, 1), axis=(0, 1)\n", + " )\n", + " mask_lap += np.roll(landmask, (-1, -1), axis=(0, 1)) + np.roll(\n", + " landmask, (1, -1), axis=(0, 1)\n", + " )\n", + " mask_lap -= 8 * landmask\n", + " coastal = np.ma.masked_array(landmask, mask_lap > 0)\n", + " coastal = coastal.mask.astype(\"int\")\n", + "\n", + " return coastal\n", + "\n", + "\n", + "def get_shore_nodes_diagonal(landmask):\n", + " \"\"\"Function that detects the shore nodes, i.e. the land nodes where\n", + " one of the 8 nearest nodes is ocean. Computes the Laplacian of landmask\n", + " and the Laplacian of the 45 degree rotated landmask.\n", + "\n", + " - landmask: the land mask built using `make_landmask`, where land cell = 1\n", + " and ocean cell = 0.\n", + "\n", + " Output: 2D array array containing the shore nodes, the shore nodes are\n", + " equal to one, and the rest is zero.\n", + " \"\"\"\n", + " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", + " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", + " mask_lap += np.roll(landmask, (-1, 1), axis=(0, 1)) + np.roll(\n", + " landmask, (1, 1), axis=(0, 1)\n", + " )\n", + " mask_lap += np.roll(landmask, (-1, -1), axis=(0, 1)) + np.roll(\n", + " landmask, (1, -1), axis=(0, 1)\n", + " )\n", + " mask_lap -= 8 * landmask\n", + " shore = np.ma.masked_array(landmask, mask_lap < 0)\n", + " shore = shore.mask.astype(\"int\")\n", + "\n", + " return shore" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "coastal = get_coastal_nodes_diagonal(landmask)\n", + "shore = get_shore_nodes_diagonal(landmask)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True)\n", + "fig.suptitle(\"Figure 2. Coast and shore\", fontsize=16)\n", + "\n", + "titles = [\n", + " \"A) Coastal points\",\n", + " \"B) Shore points\",\n", + "]\n", + "\n", + "cmap = [coastal, shore]\n", + "for i, ax in enumerate(axes):\n", + " ax.set_xlabel(\"Longitude\")\n", + " ax.set_ylabel(\"Latitude\")\n", + " ax.set_xlim(x_incorners[0, 0], x_incorners[0, -1])\n", + " ax.set_ylim(y_incorners[0, 0], y_incorners[-1, 0])\n", + " ax.set_title(titles[i])\n", + " ax.pcolormesh(\n", + " x_centers,\n", + " y_centers,\n", + " lmask.mask,\n", + " cmap=\"Blues_r\",\n", + " )\n", + " inds = np.where(cmap[i] == 1)\n", + " ax.plot(\n", + " x_centers[inds],\n", + " y_centers[inds],\n", + " \"o\",\n", + " color=\"y\",\n", + " markersize=10,\n", + " markeredgecolor=\"k\",\n", + " )" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 4: Assigning coastal velocities\n", + "\n", + "For the displacement kernel we define a velocity field that pushes the particles back to the ocean. This velocity is a vector normal to the shore.\n", + "\n", + "For the shore nodes directly next to the ocean, we can take the simple derivative of `landmask` and project the result to the `shore` array, this will capture the orientation of the velocity vectors.\n", + "\n", + "For the shore nodes that only have a diagonal component, we need to take into account the diagonal nodes also and project the vectors only onto the inside corners that border the ocean diagonally.\n", + "\n", + "Then to make the vectors unitary, we normalize them by their magnitude.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def create_displacement_field(landmask, double_cell=False):\n", + " \"\"\"Function that creates a displacement field 1 m/s away from the shore.\n", + "\n", + " - landmask: the land mask\n", + " - double_cell: Boolean for determining if you want a double cell.\n", + " Default set to False.\n", + "\n", + " Output: two 2D arrays, one for each component of the velocity.\n", + " \"\"\"\n", + " shore = get_shore_nodes(landmask)\n", + "\n", + " # nodes bordering ocean directly and diagonally\n", + " shore_d = get_shore_nodes_diagonal(landmask)\n", + " # corner nodes that only border ocean diagonally\n", + " shore_c = shore_d - shore\n", + "\n", + " # Simple derivative\n", + " Ly = np.roll(landmask, -1, axis=0) - np.roll(landmask, 1, axis=0)\n", + " Lx = np.roll(landmask, -1, axis=1) - np.roll(landmask, 1, axis=1)\n", + "\n", + " Ly_c = np.roll(landmask, -1, axis=0) - np.roll(landmask, 1, axis=0)\n", + " # Include y-component of diagonal neighbors\n", + " Ly_c += np.roll(landmask, (-1, -1), axis=(0, 1)) + np.roll(\n", + " landmask, (-1, 1), axis=(0, 1)\n", + " )\n", + " Ly_c += -np.roll(landmask, (1, -1), axis=(0, 1)) - np.roll(\n", + " landmask, (1, 1), axis=(0, 1)\n", + " )\n", + "\n", + " Lx_c = np.roll(landmask, -1, axis=1) - np.roll(landmask, 1, axis=1)\n", + " # Include x-component of diagonal neighbors\n", + " Lx_c += np.roll(landmask, (-1, -1), axis=(1, 0)) + np.roll(\n", + " landmask, (-1, 1), axis=(1, 0)\n", + " )\n", + " Lx_c += -np.roll(landmask, (1, -1), axis=(1, 0)) - np.roll(\n", + " landmask, (1, 1), axis=(1, 0)\n", + " )\n", + "\n", + " v_x = -Lx * (shore)\n", + " v_y = -Ly * (shore)\n", + "\n", + " v_x_c = -Lx_c * (shore_c)\n", + " v_y_c = -Ly_c * (shore_c)\n", + "\n", + " v_x = v_x + v_x_c\n", + " v_y = v_y + v_y_c\n", + "\n", + " magnitude = np.sqrt(v_y**2 + v_x**2)\n", + " # the coastal nodes between land create a problem. Magnitude there is zero\n", + " # I force it to be 1 to avoid problems when normalizing.\n", + " ny, nx = np.where(magnitude == 0)\n", + " magnitude[ny, nx] = 1\n", + "\n", + " v_x = v_x / magnitude\n", + " v_y = v_y / magnitude\n", + "\n", + " return v_x, v_y" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "v_x, v_y = create_displacement_field(landmask.mask.astype(int))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(1, 1, figsize=(7, 6), constrained_layout=True)\n", + "fig.suptitle(\"Figure 3. Displacement field\", fontsize=16)\n", + "\n", + "ax.set_xlabel(\"Longitude\")\n", + "ax.set_ylabel(\"Latitude\")\n", + "ax.set_xlim(x_incorners[0, 0], x_incorners[0, -1])\n", + "ax.set_ylim(y_incorners[0, 0], y_incorners[-1, 0])\n", + "land0 = ax.pcolormesh(\n", + " x_centers,\n", + " y_centers,\n", + " lmask.mask,\n", + " cmap=\"Blues_r\",\n", + ")\n", + "inds = np.where(shore == 1)\n", + "coa = ax.plot(\n", + " x_centers[inds], y_centers[inds], \"o\", color=\"y\", markersize=10, markeredgecolor=\"k\"\n", + ")\n", + "quiv = ax.quiver(\n", + " x_centers,\n", + " y_centers,\n", + " v_x,\n", + " v_y,\n", + " color=\"orange\",\n", + " angles=\"xy\",\n", + " scale_units=\"xy\",\n", + " scale=25,\n", + " width=0.005,\n", + ")\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 5: Calculate the distance to the shore\n", + "\n", + "In this tutorial, we will only displace particles that are within some distance (smaller than the grid size) to the shore.\n", + "\n", + "For this we map the distance of the coastal nodes to the shore: Coastal nodes directly neighboring the shore are $1dx$ away. Diagonal neighbors are $\\sqrt{2}dx$ away. The particles can then sample this field and will only be displaced when closer than a threshold value. This gives a crude estimate of the distance.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def distance_to_shore(landmask, dx=1):\n", + " \"\"\"Function that computes the distance to the shore. It is based in the\n", + " the `get_coastal_nodes` algorithm.\n", + "\n", + " - landmask: the land mask dUilt using `make_landmask` function.\n", + " - dx: the grid cell dimension. This is a crude approxsimation of the real\n", + " distance (be careful).\n", + "\n", + " Output: 2D array containing the distances from shore.\n", + " \"\"\"\n", + " ci = get_coastal_nodes(landmask) # direct neighbors\n", + " dist = ci * dx # 1 dx away\n", + "\n", + " ci_d = get_coastal_nodes_diagonal(landmask) # diagonal neighbors\n", + " dist_d = (ci_d - ci) * np.sqrt(2 * dx**2) # sqrt(2) dx away\n", + "\n", + " return dist + dist_d" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d_2_s = distance_to_shore(landmask.mask.astype(int))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(1, 1, figsize=(6, 5), constrained_layout=True)\n", + "\n", + "fig.suptitle(\"Figure 4. Distance to shore\", fontsize=16)\n", + "ax.set_ylabel(\"Latitude [degrees]\")\n", + "ax.set_xlabel(\"Longitude [degrees]\")\n", + "\n", + "ax.pcolormesh(\n", + " x_centers,\n", + " y_centers,\n", + " lmask.mask,\n", + " cmap=\"Blues_r\",\n", + ")\n", + "d2s = ax.scatter(x_centers, y_centers, c=d_2_s, edgecolors=\"k\")\n", + "\n", + "plt.colorbar(d2s, ax=ax, label=\"Distance [gridcells]\")\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 6: Particle and Kernels\n", + "\n", + "The distance to shore, used to flag whether a particle must be displaced, is stored in a particle `Variable` `d2s`. To visualize the displacement, the zonal and meridional displacements are stored in the variables `dU` and `dV`.\n", + "\n", + "To write the displacement vector to the output before displacing the particle, the `SetDisplacement` kernel is invoked after the advection kernel.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "DisplacementParticle = parcels.Particle.add_variable(\n", + " [\n", + " parcels.Variable(\"dU\"),\n", + " parcels.Variable(\"dV\"),\n", + " parcels.Variable(\"d2s\", initial=1e3),\n", + " ]\n", + ")\n", + "\n", + "\n", + "def SetDisplacement(particles, fieldset):\n", + " particles.d2s = fieldset.distance2shore[particles]\n", + " ptcls_nearshore = particles[particles.d2s < 0.5]\n", + "\n", + " dU, dV = fieldset.dispUV[ptcls_nearshore]\n", + "\n", + " ptcls_nearshore.dx += dU * ptcls_nearshore.dt\n", + " ptcls_nearshore.dy += dV * ptcls_nearshore.dt" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 7: Simulation\n", + "\n", + "Let us first do a simulation with the default AdvectionRK2 kernel for comparison later\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And we use the following set of 9 particles\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "npart = 3 # number of particles to be released\n", + "lon = np.linspace(4.95, 5.2, npart)\n", + "lat = np.linspace(52.95, 53.2, npart)\n", + "X, Y = np.meshgrid(lon, lat)\n", + "\n", + "runtime = timedelta(hours=100)\n", + "dt = timedelta(minutes=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pset = parcels.ParticleSet(fieldset=fieldset, x=X, y=Y)\n", + "\n", + "kernels = parcels.kernels.AdvectionRK2\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"stuck_particles.parquet\",\n", + " outputdt=timedelta(hours=1),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " kernels, runtime=runtime, dt=dt, output_file=output_file, verbose_progress=False\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's add the Fields we created above to the FieldSet and do a simulation to test the displacement of the particles as they approach the shore.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds_2d = xr.Dataset(\n", + " data_vars={\n", + " \"dispU\": ([\"lat\", \"lon\"], v_x),\n", + " \"dispV\": ([\"lat\", \"lon\"], v_y),\n", + " \"distance2shore\": ([\"lat\", \"lon\"], d_2_s),\n", + " },\n", + " coords={\n", + " \"lon\": (\n", + " [\"lon\"],\n", + " ds[\"longitude\"].values,\n", + " {\"axis\": \"X\", \"units\": \"degrees_east\"},\n", + " ),\n", + " \"lat\": (\n", + " [\"lat\"],\n", + " ds[\"latitude\"].values,\n", + " {\"axis\": \"Y\", \"units\": \"degrees_north\"},\n", + " ),\n", + " },\n", + ")\n", + "fields = {\n", + " \"dispU\": ds_2d[\"dispU\"],\n", + " \"dispV\": ds_2d[\"dispV\"],\n", + " \"distance2shore\": ds_2d[\"distance2shore\"],\n", + "}\n", + "ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)\n", + "\n", + "# Combine the original fieldset with the new 2D displacement fieldset\n", + "fieldset_land = parcels.FieldSet.from_sgrid_conventions(\n", + " ds_fset, vector_fields={\"dispUV\": (\"dispU\", \"dispV\")}\n", + ")\n", + "fieldset_with_land = fieldset + fieldset_land\n", + "fieldset_with_land.describe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pset = parcels.ParticleSet(\n", + " fieldset=fieldset_with_land, pclass=DisplacementParticle, x=X, y=Y\n", + ")\n", + "\n", + "kernels = [parcels.kernels.AdvectionRK2, SetDisplacement]\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"displace_particles.parquet\",\n", + " outputdt=timedelta(hours=1),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " kernels, runtime=runtime, dt=dt, output_file=output_file, verbose_progress=False\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 8: Output\n", + "\n", + "To visualize the effect of the displacement, the particle trajectory output can be compared to the simulation without the displacement kernel.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df_stuck = parcels.read_particlefile(\"stuck_particles.parquet\")\n", + "df_displace = parcels.read_particlefile(\"displace_particles.parquet\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(1, 1, figsize=(6, 5), constrained_layout=True)\n", + "\n", + "fig.suptitle(\"Figure 5. Trajectory difference\", fontsize=16)\n", + "ax.set_ylabel(\"Latitude [degrees]\")\n", + "ax.set_xlabel(\"Longitude [degrees]\")\n", + "\n", + "ax.pcolormesh(\n", + " x_centers,\n", + " y_centers,\n", + " lmask.mask,\n", + " cmap=\"Blues_r\",\n", + ")\n", + "for j, traj in enumerate(df_stuck.partition_by(\"particle_id\", maintain_order=True)):\n", + " label = \"stuck\" if j == 0 else None\n", + " ax.plot(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " \"-\",\n", + " linewidth=2,\n", + " zorder=3,\n", + " color=\"y\",\n", + " alpha=0.7,\n", + " label=label,\n", + " )\n", + "for j, traj in enumerate(df_displace.partition_by(\"particle_id\", maintain_order=True)):\n", + " label = \"displaced\" if j == 0 else None\n", + " ax.plot(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " \"-\",\n", + " linewidth=2,\n", + " zorder=2,\n", + " color=\"c\",\n", + " alpha=0.7,\n", + " label=label,\n", + " )\n", + "ax.plot(\n", + " X.flatten(), Y.flatten(), \"o\", color=\"w\", markersize=5, label=\"initial positions\"\n", + ")\n", + "plt.legend()\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Conclusion\n", + "\n", + "Figure 5 shows how particles are prevented from approaching the coast in a 5 day simulation. Note that to show each computation, the integration timestep (`dt`) is equal to the output timestep (`outputdt`): 1 hour. This is relatively large, and causes the displacement to be on the order of 4 km and be relatively infrequent. It is advised to use smaller `dt` in real simulations.\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Slip boundary conditions\n", + "\n", + "The reason trajectories do not neatly follow the coast in A grid velocity fields is that the lack of staggering causes both velocity components to go to zero in the same way towards the cell edge. This no-slip condition can be turned into a free-slip or partial-slip condition by separately considering the cross-shore and along-shore velocity components as in [a staggered C-grid](https://docs.oceanparcels.org/en/latest/examples/documentation_stuck_particles.html#2.-C-grids). Each interpolation of the velocity field must then be corrected with a factor depending on the direction of the boundary.\n", + "\n", + "
\n", + "\n", + "__Note__ that while the code below has been developed for A-grids, we have experience that it can also work well with B-grids. So if you encounter stuck particles in a B-grid model then it might be worth to use the `interp_method=parcels.interpolators.XPartialslip()` or `interp_method=parcels.interpolators.Xfreeslip()` solutions described below.\n", + "
\n", + "\n", + "These boundary conditions have been implemented in Parcels as `interp_method=parcels.interpolators.XPartialslip()` and `interp_method=parcels.interpolators.Xfreeslip()`, which we will show in the plot below\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cells_x = np.array([[0, 0], [1, 1], [2, 2]])\n", + "cells_y = np.array([[0, 1], [0, 1], [0, 1]])\n", + "U0 = 1\n", + "V0 = 1\n", + "U = np.array([U0, U0, 0, 0, 0, 0])\n", + "V = np.array([V0, V0, 0, 0, 0, 0])\n", + "xsi = np.linspace(0.001, 0.999)\n", + "\n", + "u_interp = U0 * (1 - xsi)\n", + "v_interp = V0 * (1 - xsi)\n", + "\n", + "u_freeslip = u_interp\n", + "v_freeslip = v_interp / (1 - xsi)\n", + "\n", + "u_partslip = u_interp\n", + "v_partslip = v_interp * (1 - 0.5 * xsi) / (1 - xsi)\n", + "\n", + "\n", + "fig = plt.figure(figsize=(15, 4), constrained_layout=True)\n", + "fig.suptitle(\"Figure 6. Boundary conditions\", fontsize=16)\n", + "gs = gridspec.GridSpec(ncols=3, nrows=1, figure=fig)\n", + "\n", + "ax0 = fig.add_subplot(gs[0, 0])\n", + "\n", + "ax0.pcolormesh(cells_x, cells_y, np.array([[0], [1]]), cmap=\"Greys\", edgecolor=\"k\")\n", + "ax0.scatter(cells_x, cells_y, c=\"w\", edgecolor=\"k\")\n", + "ax0.quiver(cells_x, cells_y, U, V, scale=15)\n", + "\n", + "ax0.plot(xsi, u_interp, linewidth=5, label=\"u_interpolation\")\n", + "ax0.plot(xsi, v_interp, linestyle=\"dashed\", linewidth=5, label=\"v_interpolation\")\n", + "ax0.set_xlim(-0.3, 2.3)\n", + "ax0.set_ylim(-0.5, 1.5)\n", + "ax0.set_ylabel(\"u - v [-]\", fontsize=14)\n", + "ax0.set_xlabel(r\"$\\xi$\", fontsize=14)\n", + "ax0.set_title(\"A) Bilinear interpolation\")\n", + "ax0.legend(loc=\"lower right\")\n", + "\n", + "\n", + "ax1 = fig.add_subplot(gs[0, 1])\n", + "\n", + "ax1.pcolormesh(cells_x, cells_y, np.array([[0], [1]]), cmap=\"Greys\", edgecolor=\"k\")\n", + "ax1.scatter(cells_x, cells_y, c=\"w\", edgecolor=\"k\")\n", + "ax1.quiver(cells_x, cells_y, U, V, scale=15)\n", + "\n", + "ax1.plot(xsi, u_freeslip, linewidth=5, label=\"u_freeslip\")\n", + "ax1.plot(xsi, v_freeslip, linestyle=\"dashed\", linewidth=5, label=\"v_freeslip\")\n", + "ax1.set_xlim(-0.3, 2.3)\n", + "ax1.set_ylim(-0.5, 1.5)\n", + "ax1.set_xlabel(r\"$\\xi$\", fontsize=14)\n", + "ax1.text(0.0, 1.3, r\"$v_{freeslip} = v_{interpolation}*\\frac{1}{1-\\xi}$\", fontsize=18)\n", + "ax1.set_title(\"B) Free slip condition\")\n", + "ax1.legend(loc=\"lower right\")\n", + "\n", + "ax2 = fig.add_subplot(gs[0, 2])\n", + "\n", + "ax2.pcolormesh(cells_x, cells_y, np.array([[0], [1]]), cmap=\"Greys\", edgecolor=\"k\")\n", + "ax2.scatter(cells_x, cells_y, c=\"w\", edgecolor=\"k\")\n", + "ax2.quiver(cells_x, cells_y, U, V, scale=15)\n", + "\n", + "ax2.plot(xsi, u_partslip, linewidth=5, label=\"u_partialslip\")\n", + "ax2.plot(xsi, v_partslip, linestyle=\"dashed\", linewidth=5, label=\"v_partialslip\")\n", + "ax2.set_xlim(-0.3, 2.3)\n", + "ax2.set_ylim(-0.5, 1.5)\n", + "ax2.set_xlabel(r\"$\\xi$\", fontsize=14)\n", + "ax2.text(\n", + " 0.0,\n", + " 1.3,\n", + " r\"$v_{partialslip} = v_{interpolation}*\\frac{1-1/2\\xi}{1-\\xi}$\",\n", + " fontsize=18,\n", + ")\n", + "ax2.set_title(\"C) Partial slip condition\")\n", + "ax2.legend(loc=\"lower right\");" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Consider a grid cell with a solid boundary to the right and vectors $(U0, V0)$ = $(1, 1)$ on the left-hand nodes, as in **figure 6**. \n", + "Parcels bilinear interpolation will interpolate in the $x$ and $y$ directions. This cell is invariant in the $y$-direction, we will only consider the effect in the direction normal to the boundary. In the x-direction, both u and v will be interpolated along $\\xi$, the normalized $x$-coordinate within the cell. This is plotted with the blue and orange dashed lines in **figure 6A**.\n", + "\n", + "A free slip boundary condition is defined with $\\frac{\\delta v}{\\delta \\xi}=0$. This means that the tangential velocity is constant in the direction normal to the boundary. This can be achieved in a kernel after interpolation by dividing by $(1-\\xi)$. The resulting velocity profiles are shown in **figure 6B**.\n", + "\n", + "A partial slip boundary condition is defined with a tangential velocity profile that decreases toward the boundary, but not to zero. This can be achieved by multiplying the interpolated velocity by $\\frac{1-1/2\\xi}{1-\\xi}$. This is shown in **figure 6C**.\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For each direction and boundary condition a different factor must be used (where $\\xi$ and $\\eta$ are the normalized x- and y-coordinates within the cell, respectively):\n", + "\n", + "- Free slip\n", + "\n", + " 1: $f_u = \\frac{1}{\\eta}$\n", + "\n", + " 2: $f_u = \\frac{1}{(1-\\eta)}$\n", + "\n", + " 4: $f_v = \\frac{1}{\\xi}$\n", + "\n", + " 8: $f_v = \\frac{1}{(1-\\xi)}$\n", + "\n", + "- Partial slip\n", + "\n", + " 1: $f_u = \\frac{1/2+1/2\\eta}{\\eta}$\n", + "\n", + " 2: $f_u = \\frac{1-1/2\\eta}{1-\\eta}$\n", + "\n", + " 4: $f_v = \\frac{1/2+1/2\\xi}{\\xi}$\n", + "\n", + " 8: $f_v = \\frac{1-1/2\\xi}{1-\\xi}$\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now simulate the three different boundary conditions by advecting the 9 particles from above in a time-evolving dataset from the [Copernicus Marine Data Store](https://data.marine.copernicus.eu/product/GLOBAL_ANALYSISFORECAST_PHY_001_024/services).\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First up is the **partialslip interpolation**\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fieldset.UV.interp_method = parcels.interpolators.XPartialslip()\n", + "\n", + "pset = parcels.ParticleSet(fieldset=fieldset, x=X, y=Y)\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"partialslip_particles.parquet\",\n", + " outputdt=timedelta(hours=1),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " parcels.kernels.AdvectionRK2,\n", + " runtime=runtime,\n", + " dt=dt,\n", + " output_file=output_file,\n", + " verbose_progress=False,\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And then we also use the **freeslip** interpolation\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fieldset.UV.interp_method = parcels.interpolators.XFreeslip()\n", + "\n", + "pset = parcels.ParticleSet(fieldset=fieldset, x=X, y=Y)\n", + "\n", + "output_file = parcels.ParticleFile(\n", + " \"freeslip_particles.parquet\",\n", + " outputdt=timedelta(hours=1),\n", + " mode=\"w\",\n", + ")\n", + "\n", + "pset.execute(\n", + " parcels.kernels.AdvectionRK2,\n", + " runtime=runtime,\n", + " dt=dt,\n", + " output_file=output_file,\n", + " verbose_progress=False,\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we can load and plot the two `interpolation_methods`\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df_freeslip = parcels.read_particlefile(\"freeslip_particles.parquet\")\n", + "df_partialslip = parcels.read_particlefile(\"partialslip_particles.parquet\")\n", + "\n", + "fig, ax = plt.subplots(1, 1, figsize=(6, 5), constrained_layout=True)\n", + "\n", + "fig.suptitle(\"Figure 7. Solution comparison\", fontsize=16)\n", + "ax.set_ylabel(\"Latitude [degrees]\")\n", + "ax.set_xlabel(\"Longitude [degrees]\")\n", + "\n", + "ax.pcolormesh(\n", + " x_centers,\n", + " y_centers,\n", + " lmask.mask,\n", + " cmap=\"Blues_r\",\n", + ")\n", + "for j, traj in enumerate(df_stuck.partition_by(\"particle_id\", maintain_order=True)):\n", + " label = \"stuck\" if j == 0 else None\n", + " ax.plot(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " \"-\",\n", + " linewidth=2,\n", + " zorder=3,\n", + " color=\"y\",\n", + " alpha=0.7,\n", + " label=label,\n", + " )\n", + "for j, traj in enumerate(\n", + " df_partialslip.partition_by(\"particle_id\", maintain_order=True)\n", + "):\n", + " label = \"partialslip\" if j == 0 else None\n", + " ax.plot(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " \"-\",\n", + " linewidth=2,\n", + " zorder=2,\n", + " color=\"r\",\n", + " alpha=0.7,\n", + " label=label,\n", + " )\n", + "for j, traj in enumerate(df_freeslip.partition_by(\"particle_id\", maintain_order=True)):\n", + " label = \"freelslip\" if j == 0 else None\n", + " ax.plot(\n", + " traj[\"x\"],\n", + " traj[\"y\"],\n", + " \"-\",\n", + " linewidth=2,\n", + " zorder=1,\n", + " color=\"m\",\n", + " alpha=0.7,\n", + " label=label,\n", + " )\n", + "ax.plot(\n", + " X.flatten(), Y.flatten(), \"o\", color=\"w\", markersize=5, label=\"initial positions\"\n", + ")\n", + "plt.legend()\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Figure 7** shows the influence of the different solutions on the particle trajectories near the shore.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Parcels:docs (3.14.6)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/examples_v3/documentation_LargeRunsOutput.ipynb b/docs/user_guide/examples_v3/documentation_LargeRunsOutput.ipynb deleted file mode 100644 index 3180e8c6d4..0000000000 --- a/docs/user_guide/examples_v3/documentation_LargeRunsOutput.ipynb +++ /dev/null @@ -1,300 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# Dealing with large output files\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "You might imagine that if you followed the instructions [on the making of parallel runs](https://docs.oceanparcels.org/en/latest/examples/documentation_MPI.html) and the loading of the resulting dataset, you could just use the `dataset.to_zarr()` function to save the data to a single `zarr` datastore. This is true for small enough datasets. However, there is a bug in `xarray` which makes this inefficient for large data sets.\n", - "\n", - "At the same time, it will often improve performance if large datasets are saved as a single `zarr` store, chunked appropriately, and the type of the variables in them modified. It is often also useful to add other variables to the dataset. This document describes how to do all this.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Why are we doing this? And what chunk sizes should we choose?\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "If you are running a relatively small case (perhaps 1/10 the size of the memory of your machine), nearly anything you do will work. However, as your problems get larger, it can help to write the data into a single zarr datastore, and to chunk that store appropriately.\n", - "\n", - "To illustrate this, here is the time it takes to retrieve all the results (with `ds['variableName'].values`) of some common data structures with different chunk sizes. (What is a chunk size? More on that below). The data in this example has 39 million trajectories starting over 120 times, and there are 250 observations, resulting in a directory size of 88Gb in double precision and 39 in single. In this table, \"trajectory:5e4, obs:10\" indicates that each chunk extends over 50,000 trajectories and 10 obs. The chunking in the original data is roughly a few thousand observations and 10 obs.\n", - "\n", - "| File type | open [s] | read 1 obs, all traj [s] | read 23 obs, all traj [s] | read 8000 contiguous traj, all obs [s] | read traj that start at a given time, all obs [s] |\n", - "| ----------------------- | -------- | ------------------------ | ------------------------- | ------------------------------------- | ------------------------------------------------- |\n", - "| Straigth from parcels | 2.9 | 8.4 | 59.9 | 1.5 | 17.4 |\n", - "| trajectory:5e4, obs:10 | 0.48 | 2.5 | 19.5 | 0.4 | 10.33 |\n", - "| trajectory:5e4, obs:100 | 0.55 | 20.5 | 13.8 | 0.5 | 3.88 |\n", - "| trajectory:5e5, obs:10 | 0.54 | 2.2 | 16.3 | 0.85 | 18.5 |\n", - "| trajectory:5e5, obs:100 | 0.46 | 19.9 | 40.0 | 0.62 | 49.36 |\n", - "\n", - "You can see several things in this. It is always quicker to open a single file, and for all data access patterns, there is are chunksizes that are more efficient than the default output. Why is this?\n", - "\n", - "The chunksize determines how data is stored on disk. For the default zarr datastore, each chunk of data is stored as a single compressed file. In netCDF, chunking is similar except that the compressed data is stored within a single file. In either case, if you must access any data from within a chunk, you must read the entire chunk from disk.\n", - "\n", - "So when we access one obs dimension and many trajectories, the chunking scheme that is elongated in the trajectory direction is fastest. When we get all the observation for a scattered set of trajectories, the chunking that is elongated in observations is the best. In general, the product of the two chunksizes (the number of data points in a chunk) should be hundreds of thousands to 10s of millions. A suboptimal chunking scheme is usually not tragic, but if you know how you will most often access the data, you can save considerable time.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "4", - "metadata": {}, - "source": [ - "## (TODO: Remove section now that we don't support MPI) How to save the output of an MPI ocean parcels run to a single zarr dataset\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "First, we need to import the necessary modules, specify the directory `inputDir` which contains the output of the parcels run (the directory that has proc01, proc02 and so forth), the location of the output zarr file `outputDir` and a dictionary giving the chunk size for the `trajectory` and `obs` coordinates, `chunksize`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "import time\n", - "from glob import glob\n", - "from os import path\n", - "\n", - "import dask\n", - "import xarray as xr\n", - "from dask.diagnostics import ProgressBar\n", - "from numpy import *\n", - "from pylab import *\n", - "\n", - "# first specify the directory in which the MPI code wrote its output\n", - "inputDir = (\n", - " \"dataPathsTemp/\"\n", - " + \"theAmericas_wholeGlobe_range100km_depthFrom_1m_to_500m_habitatTree_months01_to_02_fixed_1m/\"\n", - " + \"2007/tracks.zarr\"\n", - ")\n", - "\n", - "\n", - "# specify chunksize and where the output zarr file should go; also set chunksize of output file\n", - "chunksize = {\"trajectory\": 5 * int(1e4), \"obs\": 10}\n", - "outputDir = \"/home/pringle/jnkData/singleFile_5e4_X_10_example.zarr\"" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "Now for large datasets, this code can take a while to run; for 36 million trajectories and 250 observations, it can take an hour and a half. I prefer not to accidentally destroy data that takes more than an hour to create, so I put in a safety check and only let the code run if the output directory does not exist.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# do not overwrite existing data sets\n", - "if path.exists(outputDir):\n", - " print(\"the ouput path\", outputDir, \"exists\")\n", - " print(\"please delete if you want to replace it\")\n", - " assert False, \"stopping execution\"" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "It will often be useful to change the [dtype](https://numpy.org/doc/stable/reference/generated/numpy.dtype.html) of the output data. Doing so can save a great deal of disk space. For example, the input data for this example is 88Gb in size, but by changing lat, lon and z to single precision, I can make the file about half as big.\n", - "\n", - "This comes at the cost of some accuracy. Float64 has 14 digits of accuracy, float32 has 7. For latitude and longitude, going from float64 to float32 increases the error by the circumference of the Earth divided 1e7, or about 1m. This is good enough for what I am doing. However, a year of time has about 3.15e7 seconds, and we often want to know within a second when a particle is released (to avoid floating point issues when picking out particles that start at a specific time). So the 3.15e7/1e7 error (a few seconds) in the time coordinate could cause problems. So I don't want to reduce the precision of time.\n", - "\n", - "There is one other important issue. Due to a bug in xarray, it is much slower to save datasets with a datetime64 variable in them. So time here will be given as float64. If (as we do below) the attribute data is preserved, it will still appear as a datetime type when the data file is loaded\n", - "\n", - "To change precision, put an entry into the dictionary `varType` whose key is the name of the variable, and whose value is the type you wish the variable to be cast to:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "varType = {\n", - " \"lat\": dtype(\"float32\"),\n", - " \"lon\": dtype(\"float32\"),\n", - " \"time\": dtype(\"float64\"), # to avoid bug in xarray\n", - " \"z\": dtype(\"float32\"),\n", - "}" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "Now we need to read in the data as discussed in the section on making an MPI run. However, note that `xr.open_zarr()` is given the `decode_times=False` option, which prevents the time variable from being converted into a datetime64[ns] object. This is necessary due to a bug in xarray. As discussed above, when the data set is read back in, time will again be interpreted as a datetime.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"opening data from multiple process files\")\n", - "tic = time.time()\n", - "files = glob(path.join(inputDir, \"proc*\"))\n", - "dataIn = xr.concat(\n", - " [xr.open_zarr(f, decode_times=False) for f in files],\n", - " dim=\"trajectory\",\n", - " compat=\"no_conflicts\",\n", - " coords=\"minimal\",\n", - ")\n", - "print(\" done opening in %5.2f\" % (time.time() - tic))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "Now we can take advantage of the `.astype` operator to change the type of the variables. This is a lazy operator, and it will only be applied to the data when the data values are requested below, when the data is written to a new zarr store.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [ - "for v in varType.keys():\n", - " dataIn[v] = dataIn[v].astype(varType[v])" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "15", - "metadata": {}, - "source": [ - "The dataset is then rechunked to our desired shape. This does not actually do anything right now, but will when the data is written below. Before doing this, it is useful to remove the per-variable chunking metadata, because of inconsistencies which arise due to (I think) each MPI process output having a different chunking. This is explained in more detail in https://github.com/dcs4cop/xcube/issues/347\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"re-chunking\")\n", - "tic = time.time()\n", - "for v in dataIn.variables:\n", - " if \"chunks\" in dataIn[v].encoding:\n", - " del dataIn[v].encoding[\"chunks\"]\n", - "dataIn = dataIn.chunk(chunksize)\n", - "print(\" done in\", time.time() - tic)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "17", - "metadata": {}, - "source": [ - "The dataset `dataIn` is now ready to be written back out with dataIn.to_zarr(). Because this can take a while, it is nice to delay computation and then compute() the resulting object with a progress bar, so we know how long we have to get a cup of coffee or tea.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "delayedObj = dataIn.to_zarr(outputDir, compute=False)\n", - "with ProgressBar():\n", - " results = delayedObj.compute()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "We can now load the zarr data set we have created, and see what is in it, compared to what was in the input dataset. Note that since we have not used \"decode_times=False\", the time coordinate appears as a datetime object.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "dataOriginal = xr.concat(\n", - " [xr.open_zarr(f) for f in files],\n", - " dim=\"trajectory\",\n", - " compat=\"no_conflicts\",\n", - " coords=\"minimal\",\n", - ")\n", - "dataProcessed = xr.open_zarr(outputDir)\n", - "print(\"The original data\\n\", dataOriginal, \"\\n\\nThe new dataSet\\n\", dataProcessed)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/examples_v3/documentation_advanced_zarr.ipynb b/docs/user_guide/examples_v3/documentation_advanced_zarr.ipynb deleted file mode 100644 index 622297f4fe..0000000000 --- a/docs/user_guide/examples_v3/documentation_advanced_zarr.ipynb +++ /dev/null @@ -1,458 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "id": "0", - "metadata": { - "papermill": { - "duration": 0.058812, - "end_time": "2023-02-05T12:56:42.979406", - "exception": false, - "start_time": "2023-02-05T12:56:42.920594", - "status": "completed" - }, - "tags": [] - }, - "source": [ - "# Output in zarr (advanced)\n", - "\n", - "Topics:\n", - "\n", - "- Capturing output in memory using the `MemoryStore` of `zarr` ([link](https://zarr.readthedocs.io/en/v2.18.5/api/storage.html#zarr.storage.MemoryStore))\n", - "- Transferring data from a `MemoryStore` into a directory (via `DirectoryStore`; [link](https://zarr.readthedocs.io/en/v2.18.5/api/storage.html#zarr.storage.DirectoryStore)) or a zip file (via `ZipStore`; [link](https://zarr.readthedocs.io/en/v2.18.5/api/storage.html#zarr.storage.ZipStore)).\n", - "- Re-chunking output to better align with specific analyses (time vs. spatial filtering).\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## A simple flow field\n", - "\n", - "We'll use a Rankine Vortex (https://en.wikipedia.org/wiki/Rankine_vortex) and add some noise to make trajectories interesting.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import xarray as xr" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "np.random.seed(1234) # let's be reproducible" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "lat = xr.DataArray(np.linspace(-20, 20, 101), dims=(\"lat\",), name=\"lat\")\n", - "lon = xr.DataArray(np.linspace(-20, 20, 101), dims=(\"lon\",), name=\"lon\")\n", - "\n", - "# rankine vortex:\n", - "Vmax = 1.5\n", - "Vnoise = 0.2\n", - "phi = np.arctan2(lat, lon)\n", - "r = (lat**2 + lon**2) ** 0.5\n", - "core_rad = 10\n", - "rad_structure = xr.where(r <= core_rad, r / core_rad, core_rad / r)\n", - "U_noise = np.random.normal(0, Vnoise, size=r.shape)\n", - "V_noise = np.random.normal(0, Vnoise, size=r.shape)\n", - "\n", - "# the dataset\n", - "ds_flow_field = xr.Dataset(\n", - " {\n", - " \"U\": (Vmax * -np.sin(phi) * rad_structure + U_noise).rename(\"U\"),\n", - " \"V\": (Vmax * np.cos(phi) * rad_structure + V_noise).rename(\"V\"),\n", - " },\n", - " coords={\"lat\": lat, \"lon\": lon},\n", - ")\n", - "\n", - "display(ds_flow_field)\n", - "\n", - "((ds_flow_field.U**2 + ds_flow_field.V**2) ** 0.5).plot()\n", - "\n", - "(\n", - " ds_flow_field.isel(lon=slice(None, None, 3), lat=slice(None, None, 3)).plot.quiver(\n", - " x=\"lon\", y=\"lat\", u=\"U\", v=\"V\"\n", - " )\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "5", - "metadata": { - "papermill": { - "duration": 0.015861, - "end_time": "2023-02-05T12:56:46.746560", - "exception": false, - "start_time": "2023-02-05T12:56:46.730699", - "status": "completed" - }, - "tags": [] - }, - "source": [ - "## The Parcels experiment\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": { - "papermill": { - "duration": 0.153963, - "end_time": "2023-02-05T12:56:46.916662", - "exception": false, - "start_time": "2023-02-05T12:56:46.762699", - "status": "completed" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "from datetime import datetime, timedelta\n", - "\n", - "import numpy as np\n", - "import zarr\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "### Create fieldset from Xarray\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "fieldset = parcels.FieldSet.from_xarray_dataset(\n", - " ds_flow_field,\n", - " variables={\"U\": \"U\", \"V\": \"V\"},\n", - " dimensions={\"lat\": \"lat\", \"lon\": \"lon\"},\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "9", - "metadata": { - "papermill": { - "duration": 0.017419, - "end_time": "2023-02-05T12:56:47.032698", - "exception": false, - "start_time": "2023-02-05T12:56:47.015279", - "status": "completed" - }, - "tags": [] - }, - "source": [ - "### A randomly positioned particleset\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": { - "papermill": { - "duration": 0.031583, - "end_time": "2023-02-05T12:56:47.080957", - "exception": false, - "start_time": "2023-02-05T12:56:47.049374", - "status": "completed" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "def create_random_pset(\n", - " fieldset=None, lon_range=(-15, 15), lat_range=(-15, 15), number_particles=200\n", - "):\n", - " return parcels.ParticleSet.from_list(\n", - " fieldset=fieldset,\n", - " pclass=parcels.Particle,\n", - " lon=np.random.uniform(*lon_range, size=(number_particles,)),\n", - " lat=np.random.uniform(*lat_range, size=(number_particles,)),\n", - " time=np.zeros(shape=(number_particles,)),\n", - " )\n", - "\n", - "\n", - "pset = create_random_pset(fieldset)\n", - "\n", - "plt.plot(pset.lon, pset.lat, \"o\", alpha=0.5)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "11", - "metadata": { - "papermill": { - "duration": 0.019595, - "end_time": "2023-02-05T12:56:47.116811", - "exception": false, - "start_time": "2023-02-05T12:56:47.097216", - "status": "completed" - }, - "tags": [] - }, - "source": [ - "### Streaming to an _in-memory_ store\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "# create fresh particle set\n", - "pset = create_random_pset(fieldset)\n", - "\n", - "# create output store and output file\n", - "output_memorystore = zarr.storage.MemoryStore()\n", - "outputfile = pset.ParticleFile(name=output_memorystore, outputdt=timedelta(hours=3))\n", - "\n", - "# run experiment\n", - "pset.execute(\n", - " parcels.kernels.AdvectionRK4,\n", - " runtime=timedelta(days=17),\n", - " dt=timedelta(hours=3),\n", - " output_file=outputfile,\n", - ")\n", - "\n", - "# load output\n", - "ds_out = xr.open_zarr(output_memorystore)\n", - "display(ds_out)\n", - "\n", - "# have a look\n", - "ds_out.to_dataframe().plot.scatter(x=\"lon\", y=\"lat\", s=2, alpha=0.1);" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "## Saving to an other Zarr store\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "### Directory, without changing chunking\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# create store\n", - "output_dirstore_name = \"zarr_advanced_01.zarr/\"\n", - "!rm -rf {output_dirstore_name}\n", - "output_dirstore = zarr.storage.DirectoryStore(output_dirstore_name)\n", - "\n", - "# copy\n", - "zarr.convenience.copy_store(output_memorystore, output_dirstore)\n", - "output_dirstore.close()\n", - "\n", - "# have a quick look\n", - "ds_out = xr.open_zarr(output_dirstore_name)\n", - "display(ds_out)\n", - "ds_out.to_dataframe().plot.scatter(x=\"lon\", y=\"lat\", s=2, alpha=0.1);" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "### Zipfile, without changing chunking\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# create store\n", - "output_zipstore_name = \"zarr_advanced_01.zip\"\n", - "!rm -f {output_zipstore_name}\n", - "output_zipstore = zarr.storage.ZipStore(output_zipstore_name, mode=\"w\")\n", - "\n", - "# copy\n", - "zarr.convenience.copy_store(output_memorystore, output_zipstore)\n", - "output_zipstore.close()\n", - "\n", - "# have a quick look\n", - "ds_out = xr.open_zarr(output_zipstore_name)\n", - "display(ds_out)\n", - "ds_out.to_dataframe().plot.scatter(x=\"lon\", y=\"lat\", s=2, alpha=0.1);" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "### Rechunking and saving to new stores\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "Consider the current chunking (let's load the memory store again):\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "ds_out_orig = xr.open_zarr(output_memorystore)\n", - "display(ds_out_orig.lat.data)\n", - "display(ds_out_orig.chunks)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "21", - "metadata": {}, - "source": [ - "So we have one chunk per `obs` slice which covers all trajectories. This is the optimal way of _creating_ the recorded dataset at simulation time, but may not be ideal for, e.g., time filtering.\n", - "\n", - "So let's turn this into a store which only covers 10 trajectories per chunk but 40 time steps:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "22", - "metadata": {}, - "outputs": [], - "source": [ - "ds_out_rechunked = ds_out_orig.chunk({\"trajectory\": 10, \"obs\": 40})\n", - "display(ds_out_rechunked.lat.data)\n", - "display(ds_out_rechunked.chunks)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "23", - "metadata": {}, - "source": [ - "And of course, we could now save the rechunked data into a zip store or a directory store:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "# necessary because of https://github.com/pydata/xarray/issues/4380\n", - "for vname, vobj in ds_out_rechunked.data_vars.items():\n", - " if \"chunks\" in vobj.encoding:\n", - " del vobj.encoding[\"chunks\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "ds_out_rechunked.to_zarr(\"zarr_advanced_01_rechunked.zarr/\", mode=\"w\")\n", - "# new directory store\n", - "ds_out_rechunked.to_zarr(\n", - " zarr.storage.ZipStore(\"zarr_advanced_01_rechunked.zip\", mode=\"w\")\n", - "); # new zip store" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.6" - }, - "papermill": { - "default_parameters": {}, - "duration": 13.423996, - "end_time": "2023-02-05T12:56:54.785370", - "environment_variables": {}, - "exception": null, - "input_path": "2023-02-03_first_steps.ipynb", - "output_path": "2023-02-03_first_steps.exe.ipynb", - "parameters": {}, - "start_time": "2023-02-05T12:56:41.361374", - "version": "2.3.4" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/examples_v3/documentation_geospatial.ipynb b/docs/user_guide/examples_v3/documentation_geospatial.ipynb deleted file mode 100644 index 9840e99bf3..0000000000 --- a/docs/user_guide/examples_v3/documentation_geospatial.ipynb +++ /dev/null @@ -1,719 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Parcels trajectories with geospatial data types and software" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Summary:** \n", - "This tutorial goes over converting parcels particle trajectory data into various common geospatial data formats including:\n", - "\n", - "* Shapefile\n", - "* Geopackage\n", - "* GeoJSON\n", - "* KML\n", - "\n", - "This allows for easy integration of Parcels output with any geospatial workflows you have, or geospatial tools you would like for further visualization and analysis. Geospatial software covered in this tutorial include:\n", - "\n", - "* GIS Software ([QGIS](https://qgis.org/en/site/) and [ArcGIS](https://www.arcgis.com/index.html))\n", - "* [Kepler.gl](https://kepler.gl/)\n", - "* [Google Earth](https://www.google.com/earth/versions/)\n", - "\n", - "This code has been written to make it as \"production ready\" and \"copy pastable\" as possible to help with your workflows. This tutorial also highlights the limitations of some geospatial datasets and tools when it comes to working with large datasets (which parcels tends to output).\n", - "\n", - "**Tutorial requirements:** \n", - "Most of this tutorial requires your simulation is run using longitude and latitude in degrees (as opposed to being in metres).\n", - "\n", - "This tutorial requires the following packages:\n", - "- [geopandas](https://geopandas.org/) to create the geospatial datasets (`conda install -c conda-forge geopandas`)\n", - "- [lxml](https://lxml.de/) to create KML files for Google Earth (`conda install lxml`)\n", - "\n", - "This tutorial saves all generated data output in a folder `tutorial_geospatial_output` to avoid clogging the working directory. If you're re-running this notebook, be sure to delete this folder so that the notebook doesn't encounter writing issues." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## GIS data types\n", - "\n", - "There are mainly two types of geospatial data:\n", - "* Raster data\n", - "* Vector data\n", - "\n", - "See [this article](https://gisgeography.com/spatial-data-types-vector-raster/) for an explainer of raster vs vector data types.\n", - "\n", - "Various geospatial dataset types exist for raster and vector data. Here we go over a few of them, discussing their advantages and disadvantages.\n", - "\n", - "\n", - "### NetCDF (.nc)\n", - "A raster data format for storing and sharing large, multi-dimensional arrays of scientific data, often used for gridded climate and oceanographic datasets. May need some processing before visualizing depending on setup of the NetCDF.\n", - "\n", - "### Shapefile (.shp, ...)\n", - "The longest standing geospatial vector data format used for storing location data and associated attribute information. Shapefile is a multifile format, where the dataset is split into files with different extensions handling vector (.shp), indexing (.shx), attribute (.dbf), encoding (.cpg), and projection (.prj) information. The shapefile is a [geospatial data format with many limitations](http://switchfromshapefile.org/) compared to other geospatial data formats.\n", - "\n", - "- Pros:\n", - " - The oldest data format. Has wide adoption across various GIS tools.\n", - "- Cons:\n", - " - Limits on:\n", - " - variable name lengths (10 characters)\n", - " - variable types\n", - " - file size (2Gb max)\n", - " - No NULL value\n", - " - No time data type.\n", - " - Can only contain a single layer (i.e. you can't mix geospatial data types in a single file).\n", - "\n", - "\n", - "### GeoJSON (.geojson)\n", - "A lightweight, human-readable, vector data format for encoding geospatial data structures, such as points, lines, and polygons, using JavaScript Object Notation (JSON). Commonly used in web applications.\n", - "\n", - "\n", - "### KML (.kml) and KMZ (.kmz)\n", - "XML-based vector formats for expressing geographic annotations and visualizations on 2D maps and 3D Earth browsers, such as Google Earth. KMZ is a compressed version of KML.\n", - "\n", - "### GeoPackage (.gpkg)\n", - "A modern, open, standards-based, platform-independent format for storing geospatial data. Operates using an SQLite database, and is capable of storing multiple layers in a single dataset. Can contain raster and vector layers.\n", - "\n", - "\n", - "### Geodatabase (.gdb)\n", - "A proprietary Esri format for storing, querying, and managing geospatial data, including vector, raster, and tabular data. This tutorial does not cover Geodatabases.\n", - "\n", - "\n", - "---\n", - "For geospatial vector datasets, layers can only contain one feature type. Each feature in a geospatial dataset can have associated data (timestamps, text, numeric values) depending on what the data represents. There are 3 types of features in geospatial datasets:\n", - "\n", - "- **Point:** Describes individual coordinates on earth.\n", - "- **Linestring:** Describes individual lines on earth (is composed of a sequence of coordinates, which are joined in an open loop).\n", - "- **Polygon:** Describes individual polygons on earth (is composed of a sequence of coordinates, which are joined in a closed loop).\n", - "\n", - "Here there are two logical choices for representing particle trajectory data, using *points* or *linestrings*. Using points, all the data (including timestamps) is preserved, but the path of the particle may not be inherently evident on the software being used to load the point data. The timestamp information is tied as data for that point, and may not be visualized by the software. Representing trajectories as linestrings makes it easy to visualize trajectories in GIS software, but extra data regarding the individual point observations (i.e. all data except (lon, lat) location data) is lost in this format.\n", - "\n", - "These limitations mainly apply to Shapefile, GeoJSON, GeoPackage, and Geodatabase formats when used in GIS applications. As explored later, Google Earth (KML) has syntax specifically for visualizing trajectories, and Kepler accepts modified GeoJSON to represent trajectory data.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Creating geospatial datasets from parcels output\n", - "First, we run import all the packages needed for this tutorial, run an example parcels simulation and save the output. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import datetime\n", - "import json\n", - "from datetime import timedelta\n", - "from pathlib import Path\n", - "\n", - "import geopandas as gpd\n", - "import numpy as np\n", - "import requests\n", - "import xarray as xr\n", - "from lxml import etree\n", - "from shapely.geometry import LineString, Point\n", - "\n", - "import parcels\n", - "\n", - "DATA_OUTPUT_FOLDER = Path(\"tutorial_geospatial_output\")\n", - "DATA_OUTPUT_FOLDER.mkdir(exist_ok=True)\n", - "DATA_OUTPUT_NAME = \"agulhas_trajectories\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# An example parcels simulation\n", - "data_folder = parcels.download_example_dataset(\"GlobCurrent_example_data\")\n", - "filenames = filename = str(data_folder / \"20*.nc\")\n", - "variables = {\n", - " \"U\": \"eastward_eulerian_current_velocity\",\n", - " \"V\": \"northward_eulerian_current_velocity\",\n", - "}\n", - "dimensions = {\n", - " \"lat\": \"lat\",\n", - " \"lon\": \"lon\",\n", - " \"time\": \"time\",\n", - "}\n", - "\n", - "fieldset = parcels.FieldSet.from_netcdf(filenames, variables, dimensions)\n", - "\n", - "# Mesh of particles\n", - "lons, lats = np.meshgrid(range(15, 35, 2), range(-40, -30, 2))\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=parcels.Particle, lon=lons, lat=lats\n", - ")\n", - "dt = timedelta(hours=24)\n", - "output_file = pset.ParticleFile(\n", - " name=DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}.zarr\", outputdt=dt\n", - ")\n", - "\n", - "\n", - "def DeleteParticle(particle, fieldset, time):\n", - " if particle.state > 4:\n", - " particle.delete()\n", - "\n", - "\n", - "pset.execute(\n", - " [parcels.kernels.AdvectionRK4, DeleteParticle],\n", - " runtime=timedelta(days=120),\n", - " dt=dt,\n", - " output_file=output_file,\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we have a zarr dataset stored in the `tutorial_geospatial_example_trajectories.zarr` folder. We can open this using xarray into an `xarray.Dataset` object and process it into different geospatial datasets.\n", - "\n", - "First up is the easiest, **NetCDF**." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds_parcels = xr.open_zarr(DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}.zarr\")\n", - "\n", - "ds_parcels.to_netcdf(DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}.nc\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "As the NetCDF format is very similar to zarr, there is no need for extra processing. The other datatypes will require some more work to convert the raster data to a vector format.\n", - "\n", - "For this, `geopandas` is used which extends the `pandas` DataFrame object with various geospatial capabilities to create a `GeoDataFrame` object ([learn more about the geopandas project](https://geopandas.org/)). Geopandas, like pandas, operates with data in-memory, which will require the data to be loaded into RAM (as opposed to being lazy loaded like with NetCDF and zarr datasets). To avoid loading in huge datasets, we code in some safeguards. If your dataset is too large, it is recommended you subset your `xr.Dataset` to reduce its size before proceeding with converting to the geospatial datasets." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def parcels_to_geopandas(ds, suppress_warnings=False):\n", - " \"\"\"\n", - " Converts your parcels data to a geopandas dataframe containing a point for\n", - " every observation in the dataframe. Custom particle variables come along\n", - " for the ride during the transformation. Any undefined observations are removed\n", - " (correspond to the particle being deleted, or not having entered the simulation).\n", - "\n", - " Assumes your parcel output is in lat and lon.\n", - "\n", - " Parameters\n", - " ----------\n", - " ds : xr.Dataset\n", - " Dataset object in the format of parcels output.\n", - "\n", - " suppress_warnings : bool\n", - " Whether to ignore RAM warning.\n", - "\n", - " Returns\n", - " -------\n", - " geopandas.GeoDataFrame\n", - " GeoDataFrame with point data for each particle observation in the dataset.\n", - " \"\"\"\n", - " RAM_LIMIT_BYTES = 4 * 1000 * 1000 # 4 GB RAM limit\n", - "\n", - " if ds.nbytes > RAM_LIMIT_BYTES and not suppress_warnings:\n", - " raise MemoryError(\n", - " f\"Dataset is {ds.nbytes:_} bytes, but RAM_LIMIT_BYTES set max to be {RAM_LIMIT_BYTES:_}.\"\n", - " )\n", - "\n", - " df = (\n", - " ds.to_dataframe().reset_index() # Convert `obs` and `trajectory` indices to be columns instead\n", - " )\n", - "\n", - " gdf = (\n", - " gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df[\"lon\"], df[\"lat\"]))\n", - " .drop(\n", - " [\"lon\", \"lat\"], axis=1\n", - " ) # No need for lon and lat cols. Included in geometry attribute\n", - " .set_crs(\n", - " \"EPSG:4326\"\n", - " ) # Set coordinate reference system to EPSG:4326 (aka. WGS84; the lat lon reference system)\n", - " )\n", - "\n", - " # Remove observations with no time from gdf (indicate particle has been removed, or isn't in simulation)\n", - " invalid_observations = gdf[\"time\"].isna()\n", - " return gdf[~invalid_observations]\n", - "\n", - "\n", - "gdf_parcels = parcels_to_geopandas(ds_parcels)\n", - "gdf_parcels" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Output GeoDataFrame to geospatial file format (format inferred from extension)\n", - "gdf_parcels.to_file(DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}.geojson\")\n", - "gdf_parcels.to_file(DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}.gpkg\")\n", - "\n", - "# Shapefile can't handle datetime objects. Converting to seconds since 01/01/1970.\n", - "gdf_shapefile = gdf_parcels.copy()\n", - "gdf_shapefile[\"time\"] = (\n", - " gdf_shapefile[\"time\"] - datetime.datetime(1970, 1, 1)\n", - ").dt.total_seconds()\n", - "gdf_shapefile.to_file(\n", - " DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}_shapefile\"\n", - ") # Saves in dedicated subfolder all shapefile component files\n", - "\n", - "# Converting to KML is covered in the Google Earth software section" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we have our observation data in vector format as individual points, lets create linestring objects for each trajectory. We then load in both geopackage files (point data, and trajectory linestrings) into QGIS for visualization." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Creating linestring objects\n", - "linestrings = [\n", - " # trajectory_idx, linestring\n", - "]\n", - "for trajectory_idx, trajectory_gdf in gdf_parcels.groupby(\"trajectory\"):\n", - " trajectory_gdf = trajectory_gdf.sort_values(\"obs\")\n", - " points = trajectory_gdf[\"geometry\"]\n", - " if points.shape[0] == 1:\n", - " continue # Can't create LineString with one point\n", - " linestring = LineString()\n", - " linestrings.append((trajectory_idx, linestring))\n", - "\n", - "gdf_parcels_linestring = gpd.GeoDataFrame(\n", - " linestrings, columns=[\"trajectory\", \"geometry\"]\n", - ")\n", - "\n", - "# Save linestrings to geospatial dataset using the same commands as before\n", - "# E.g. geopackage\n", - "gdf_parcels_linestring.to_file(\n", - " DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}_linestring.gpkg\"\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "![](images/tutorial_geospatial_qgis.png)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Geopandas is extremely versatile as a tool to explore and analyze geospatial data. The usefulness of geopandas when it comes to analysing parcels output is further explored in the example at the end of this tutorial.\n", - "\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Google Earth\n", - "Here we convert the trajectories to KML using `gx:Track` objects (which can have time encoded in the trajectory). We use `lxml`, a general purpose XML data editor. `fastkml` is a good option for creating KML files, but in this case doesn't explicitly support `gx` objects in its API. KMZ is just a compressed version of KML, and is not covered here.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def parcels_geopandas_to_kml(\n", - " gdf: gpd.GeoDataFrame,\n", - " path,\n", - " document_name=\"Parcels Particle Trajectories\",\n", - " rubber_ducks=True,\n", - "):\n", - " \"\"\"Writes parcels trajectories to KML file.\n", - "\n", - " Converts the GeoDataFrame from the `parcels_to_geopandas` function into KML\n", - " for use in Google Earth. Each particle trajectory is converted to a gx:Track item\n", - " to include timestamp information in the path.\n", - "\n", - " Only uses the trajectory ID, time, lon, and lat variables.\n", - "\n", - " Parameters\n", - " ----------\n", - " gdf : gpd.GeoDataFrame\n", - " GeoDataFrame as output from the `parcels_to_geopandas` function\n", - "\n", - " path : pathlike\n", - " Path to save the KML to.\n", - "\n", - " rubber_ducks : bool\n", - " Replace default particle marker with rubber duck icon.\n", - "\n", - "\n", - " See also\n", - " --------\n", - " More on gx:Track in KML:\n", - " https://developers.google.com/kml/documentation/kmlreference#gx:track\n", - " \"\"\"\n", - " # Define namspaces\n", - " kml_ns = \"http://www.opengis.net/kml/2.2\"\n", - " gx_ns = \"http://www.google.com/kml/ext/2.2\"\n", - "\n", - " kml_out = etree.Element(\"{%s}kml\" % kml_ns, nsmap={None: kml_ns, \"gx\": gx_ns})\n", - " document = etree.SubElement(\n", - " kml_out, \"Document\", id=\"1\", name=\"Particle trajectories\"\n", - " )\n", - "\n", - " # Custom icon styling\n", - " if rubber_ducks:\n", - " icon_styling = etree.fromstring(\n", - " f\"\"\"\n", - " \"\"\"\n", - " )\n", - " document.append(icon_styling)\n", - "\n", - " # Generating gx:Track items\n", - " for trajectory_idx, trajectory_gdf in gdf.groupby(\"trajectory\"):\n", - " trajectory_gdf = trajectory_gdf.sort_values(\"obs\")\n", - "\n", - " placemark = etree.SubElement(document, \"Placemark\")\n", - " name_element = etree.SubElement(placemark, \"name\")\n", - " name_element.text = str(trajectory_idx)\n", - "\n", - " # Link custom icon styling\n", - " if rubber_ducks:\n", - " style_url = etree.SubElement(placemark, \"styleUrl\")\n", - " style_url.text = \"#iconStyle\"\n", - "\n", - " gx_track = etree.SubElement(placemark, \"{%s}Track\" % gx_ns)\n", - " etree.SubElement(gx_track, \"{%s}altitudeMode\" % gx_ns, text=\"clampToGround\")\n", - "\n", - " for time in trajectory_gdf[\"time\"]:\n", - " when_element = etree.SubElement(gx_track, \"when\")\n", - " when_element.text = time.strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n", - "\n", - " for _, row in trajectory_gdf.iterrows():\n", - " gx_coord_element = etree.SubElement(gx_track, \"{%s}coord\" % gx_ns)\n", - " gx_coord_element.text = f\"{row['geometry'].x} {row['geometry'].y} 0\"\n", - "\n", - " # Save the KML to a file\n", - " with open(path, \"wb\") as f:\n", - " f.write(etree.tostring(kml_out, pretty_print=True))\n", - " return" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "parcels_geopandas_to_kml(gdf_parcels, DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}.kml\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Opening this kml file in Google Earth, we can explore the particle trajectories interactively:\n", - "![Rubber ducks in the ocean](images/tutorial_geospatial_google_earth.png)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "tags": [] - }, - "source": [ - "## Kepler.gl\n", - "> [Kepler.gl](https://kepler.gl/) is a data-agnostic, high-performance web-based application for visual exploration of large-scale geolocation data sets. Built on top of Mapbox GL and deck.gl, kepler.gl can render millions of points representing thousands of trips and perform spatial aggregations on the fly \n", - "> - [Kepler Docs](https://docs.kepler.gl/)\n", - "\n", - "For smaller visualizations, using the Kepler demo web interface is enough. To animate trips/trajectories in the web interface some customisation to the geojson file is required.\n", - "\n", - "> In order to animate the path, the geoJSON data needs to contain `LineString` in its feature geometry, and the coordinates in the LineString need to have 4 elements in the formats of `[longitude, latitude, altitude, timestamp]` with the last element being a timestamp. Valid timestamp formats include unix in seconds such as `1564184363` or in milliseconds such as `1564184363000`.\n", - "> - Kepler.gl tooltip\n", - "\n", - "\n", - "To do this, we can't use the same approach as before (as LineString from shapely takes a maximum of 3 values). We instead create the geojson manually." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def create_feature_geojson(properties, coordinates):\n", - " \"\"\"Helper function for creating Kepler geojson\"\"\"\n", - " return {\n", - " \"type\": \"Feature\",\n", - " \"properties\": {**properties},\n", - " \"geometry\": {\"type\": \"LineString\", \"coordinates\": coordinates},\n", - " }\n", - "\n", - "\n", - "def create_kepler_geojson(gdf, path):\n", - " \"\"\"\n", - " Converts the GeoDataFrame from the `parcels_to_geopandas` function into geojson\n", - " compatible with the Kepler online viewer, and writes it to a path.\n", - "\n", - " Parameters\n", - " ----------\n", - " gdf : gpd.GeoDataFrame\n", - " GeoDataFrame as output from the `parcels_to_geopandas` function\n", - "\n", - " path : pathlike\n", - " Path to save the Kepler geojson to.\n", - " \"\"\"\n", - " features = []\n", - " for trajectory_idx, trajectory_gdf in gdf.groupby(\"trajectory\"):\n", - " trajectory_gdf = trajectory_gdf.sort_values(\"obs\")\n", - "\n", - " # Extracting point coordinates\n", - " trajectory_gdf[\"epoch\"] = (\n", - " trajectory_gdf[\"time\"] - datetime.datetime(1970, 1, 1)\n", - " ).dt.total_seconds()\n", - " coordinates = [\n", - " [float(row[\"geometry\"].x), float(row[\"geometry\"].y), 0, int(row[\"epoch\"])]\n", - " for _, row in trajectory_gdf.iterrows()\n", - " ]\n", - "\n", - " feature_geojson = create_feature_geojson(\n", - " {\"pid\": int(trajectory_gdf.iloc[0][\"trajectory\"])}, coordinates\n", - " )\n", - " features.append(feature_geojson)\n", - "\n", - " kepler_geojson = {\"type\": \"FeatureCollection\", \"features\": features}\n", - " with open(path, \"w\") as f:\n", - " json.dump(kepler_geojson, f)\n", - " return" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "create_kepler_geojson(\n", - " gdf_parcels, DATA_OUTPUT_FOLDER / f\"{DATA_OUTPUT_NAME}_kepler.geojson\"\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can then load this into the Kepler.gl website. The format of the geojson will be recognised as \"Path\" for the layer. To get good visuals, its important to tweak the display:\n", - "\n", - "* Trail length (measured in seconds)\n", - "* Trail width\n", - "* Color (can also color by attribute in the data. E.g. color by particle ID)\n", - "\n", - "For this simulation, a trail length of 2 day (172800 seconds) provides good visuals." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "![Particle trajectories in Kepler](images/tutorial_geospatial_kepler.png)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we can visualize our trajectories in Kepler!!\n", - "\n", - "---" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example: Filtering particles by starting location\n", - "Now that we have our parcels data in a geodataframe, we can perform spatial operations with the trajectory output and other datasets. This enables deeper geospatial analysis of particle trajectories, examples including:\n", - "\n", - "* Finding particles that start in geographic regions\n", - "* Finding particles that end in geographic regions\n", - "* Finding particles that enter within `x` km of a coastline.\n", - "\n", - "In this worked example, we simply want to find out which of the particles start on land so we can exclude them from the analysis.\n", - "\n", - "The steps we follow are:\n", - "\n", - "* Step 1: Obtain our geospatial dataset we want to compare against\n", - " * Here we use [ESRI's World Countries Dataset](https://hub.arcgis.com/datasets/esri::world-countries-generalized) in geojson format to get polygons of the countries. Alternatively you can create your own polygons in GIS software and export as geojson/shapefile/geopackage for use in Python.\n", - "* Step 2: Filter observations to get first observation only for each particle\n", - "* Step 3: Spatial join our data\n", - "* Step 4: Filter observations to get only the particles in the water\n", - "* Step 5: Use particle IDs to subset the original xarray dataset, which can be further analyzed.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Step 1: Obtain our geospatial datasets we want to compare against\n", - "esri_dataset_url = \"https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/World_Countries_(Generalized)/FeatureServer/0/query?outFields=*&where=1%3D1&f=geojson\"\n", - "\n", - "response = requests.get(esri_dataset_url)\n", - "if response.status_code == 200:\n", - " geojson_data = response.json()\n", - "else:\n", - " raise Exception(\n", - " f\"Failed data request for {esri_dataset_url}. Status code {response.status_code}\"\n", - " )\n", - "\n", - "gdf_countries = gpd.GeoDataFrame.from_features(geojson_data[\"features\"]).set_crs(\n", - " \"EPSG:4326\"\n", - ")\n", - "gdf_countries.head()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Step 2: Filter observations to get first observation only for each particle\n", - "gdf_parcels_initial = gdf_parcels.drop_duplicates(subset=\"trajectory\", keep=\"first\")\n", - "\n", - "# Step 3: Spatial join our data\n", - "gdf_parcels_initial = gdf_parcels_initial.sjoin(\n", - " gdf_countries[[\"geometry\", \"COUNTRY\"]], how=\"left\", predicate=\"intersects\"\n", - ")\n", - "gdf_parcels_initial.tail()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Step 4: Filter observations to get only the particles in the water\n", - "water_particles_mask = gdf_parcels_initial[\"COUNTRY\"].isna()\n", - "\n", - "particles_in_water = gdf_parcels_initial[water_particles_mask][\"trajectory\"].values\n", - "particles_on_land = gdf_parcels_initial[~water_particles_mask][\"trajectory\"].values\n", - "\n", - "print(\n", - " f\"Particles with the following trajectory IDs are start in water:\\n{particles_in_water}\\n\"\n", - ")\n", - "print(\n", - " f\"Particles with the following trajectory IDs are start on land:\\n{particles_on_land}\"\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "These land particles match the particles on land in the Google Earth visualization from earlier." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Step 5: Use particle IDs to subset the original xarray dataset, which can be further analyzed.\n", - "ds_parcels_in_water = ds_parcels.sel(trajectory=particles_in_water)\n", - "\n", - "# Continue analysis..." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "That's it! You can now integrate Parcels with a variety of geospatial applications.\n", - "\n", - "If there is any additional info, corrections, or geospatial tooling you feel this tutorial can benefit from mentioning, please submit an issue or pull request in the [OceanParcels GitHub](https://github.com/oceanparcels/parcels)." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/documentation_homepage_animation.ipynb b/docs/user_guide/examples_v3/documentation_homepage_animation.ipynb deleted file mode 100644 index 7a1fd98343..0000000000 --- a/docs/user_guide/examples_v3/documentation_homepage_animation.ipynb +++ /dev/null @@ -1,191 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# The homepage animation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "raises-exception" - ] - }, - "outputs": [], - "source": [ - "%matplotlib qt\n", - "import copy\n", - "\n", - "import cartopy\n", - "import cartopy.crs as ccrs\n", - "import matplotlib.animation as animation\n", - "import matplotlib.gridspec as gridspec\n", - "import matplotlib.image as mpimg\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import xarray as xr\n", - "from matplotlib import colors\n", - "from matplotlib.animation import FuncAnimation, PillowWriter, writers" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Load in particle data, define the time range at which to plot (**`plottimes`**) and select the indices of the first timestep in the variable `'b'`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "raises-exception" - ] - }, - "outputs": [], - "source": [ - "filename = \"medusarun.nc\"\n", - "pfile = xr.open_dataset(str(filename), decode_cf=True)\n", - "lon = np.ma.filled(pfile.variables[\"lon\"], np.nan)\n", - "lat = np.ma.filled(pfile.variables[\"lat\"], np.nan)\n", - "time = np.ma.filled(pfile.variables[\"time\"], np.nan)\n", - "\n", - "pfile.close()\n", - "\n", - "plottimes = np.arange(time[0, 0], np.nanmax(time), np.timedelta64(10, \"D\"))\n", - "starttime = 20\n", - "b = time == plottimes[0 + starttime]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The particle data in `medusarun.nc` is available at https://surfdrive.surf.nl/files/index.php/s/lwjW5w05jtHuYz9\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The animation consists of three figures: the northern hemisphere, the southern hemisphere and the oceanparcels logo. To organize their locations we use [matplotlib.gridspec](https://matplotlib.org/tutorials/intermediate/gridspec.html). The animation spans 12 frames and updates the particle positions based on the timestep in `plottimes`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "raises-exception" - ] - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(8, 4))\n", - "gs = gridspec.GridSpec(ncols=8, nrows=4, figure=fig)\n", - "\n", - "### Northern Hemisphere\n", - "ax1 = fig.add_subplot(\n", - " gs[:, :4],\n", - " projection=ccrs.NearsidePerspective(\n", - " central_latitude=90, central_longitude=-30, satellite_height=15000000\n", - " ),\n", - ")\n", - "ax1.add_feature(cartopy.feature.LAND, zorder=1)\n", - "ax1.add_feature(cartopy.feature.OCEAN, zorder=1)\n", - "ax1.coastlines()\n", - "scat1 = ax1.scatter(\n", - " lon[b],\n", - " lat[b],\n", - " marker=\".\",\n", - " s=25,\n", - " c=\"#AB2200\",\n", - " edgecolor=\"white\",\n", - " linewidth=0.15,\n", - " transform=ccrs.PlateCarree(),\n", - ")\n", - "\n", - "### Southern Hemisphere\n", - "ax2 = fig.add_subplot(\n", - " gs[:, 4:],\n", - " projection=ccrs.NearsidePerspective(\n", - " central_latitude=-90, central_longitude=-30, satellite_height=15000000\n", - " ),\n", - ")\n", - "ax2.add_feature(cartopy.feature.LAND, zorder=1)\n", - "ax2.add_feature(cartopy.feature.OCEAN, zorder=1)\n", - "ax2.coastlines()\n", - "scat2 = ax2.scatter(\n", - " lon[b],\n", - " lat[b],\n", - " marker=\".\",\n", - " s=25,\n", - " c=\"#AB2200\",\n", - " edgecolor=\"white\",\n", - " linewidth=0.15,\n", - " transform=ccrs.PlateCarree(),\n", - ")\n", - "\n", - "frames = np.arange(0, 20)\n", - "\n", - "\n", - "def animate(t):\n", - " b = time == plottimes[t + starttime]\n", - " scat1.set_offsets(np.vstack((lon[b], lat[b])).transpose())\n", - " scat2.set_offsets(np.vstack((lon[b], lat[b])).transpose())\n", - " return scat1, scat2\n", - "\n", - "\n", - "anim = animation.FuncAnimation(fig, animate, frames=frames, interval=150, blit=True)\n", - "anim\n", - "\n", - "# needed for tight_layout to work with cartopy\n", - "fig.canvas.draw()\n", - "plt.tight_layout()\n", - "# writergif = PillowWriter(fps=6)\n", - "# anim.save('homepageshort.gif', writer=writergif, savefig_kwargs={\"transparent\": True})" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The resulting animation is then\n", - "\n", - "![gif](images/homepage.gif)" - ] - } - ], - "metadata": { - "celltoolbar": "Metagegevens bewerken", - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.11" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/documentation_indexing.ipynb b/docs/user_guide/examples_v3/documentation_indexing.ipynb deleted file mode 100644 index af60f5b20e..0000000000 --- a/docs/user_guide/examples_v3/documentation_indexing.ipynb +++ /dev/null @@ -1,277 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Grid indexing\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Parcels supports `Fields` on any curvilinear `Grid`. For velocity `Fields` (`U`, `V` and `W`), there are some additional restrictions if the `Grid` is discretized as an Arakawa B- or Arakawa C-grid. That is because under the hood, Parcels uses a specific interpolation scheme for each of these grid types. This is described in [Section 2 of Delandmeter and Van Sebille (2019)](https://www.geosci-model-dev.net/12/3571/2019/gmd-12-3571-2019.html) and summarized below.\n", - "\n", - "The summary is: \n", - "\n", - "> For Arakawa B-and C-grids, Parcels requires the locations of the _corner_ points (f-points) of the grid cells for the `dimensions` dictionary of velocity `Fields`.\n", - " \n", - "In other words, on an Arakawa C-grid, the `[k, j, i]` node of `U` will _not_ be located at position `[k, j, i]` of `U.grid`.\n", - "\n", - "## Barycentric coordinates and indexing in Parcels\n", - "\n", - "### Arakawa C-grids\n", - "\n", - "In a regular grid (also called an Arakawa A-grid), the velocities (`U`, `V` and `W`) and tracers (e.g. temperature) are all located in the center of each cell. But hydrodynamic model data is often supplied on staggered grids (e.g. for NEMO, POP and MITgcm), where `U`, `V` and `W` are shifted with respect to the cell centers.\n", - "\n", - "To keep it simple, let's take the case of a 2D Arakawa C-grid. Zonal (`U`) velocities are located at the east and west faces of each cell and meridional (`V`) velocities at the north and south. The following diagram shows a comparison of 4x4 A- and C-grids.\n", - "\n", - "![Arakawa Grid layouts](images/grid_comparison.png)\n", - "\n", - "Note that different models use different indices to relate the location of the staggered velocities to the cell centers. The default C-grid indexing notation in Parcels is the same as for **NEMO** (middle panel): `U[j, i]` is located between the cell corners `[j-1, i]` and `[j, i]`, and `V[j, i]` is located between cell corners `[j, i-1]` and `[j, i]`.\n", - "\n", - "Now, as you've noticed on the grid illustrated on the figure, there are 4x4 cells. The grid providing the cell corners is a 5x5 grid, but there are only 4x5 U nodes and 5x4 V nodes, since the grids are staggered. This implies that the first row of `U` data and the first column of `V` data is never used (and do not physically exist), but the `U` and `V` fields are correctly provided on a 5x5 table. If your original data are provided on a 4x5 `U` grid and a 5x4 `V` grid, you need to regrid your table to follow Parcels notation before creating a FieldSet!\n", - "\n", - "**MITgcm** uses a different indexing: `U[j, i]` is located between the cell corners `[j, i]` and `[j+1, i]`, and `V[j, i]` is located between cell corners `[j, i]` and `[j, i+1]`. If you use [xmitgcm](https://xmitgcm.readthedocs.io/en/latest/) to write your data to a NetCDF file, `U` and `V` will have the same dimensions as your grid (in the above case 4x4 rather than 5x5 as in NEMO), meaning that the last column of `U` and the last row of `V` are omitted. In MITgcm, these either correspond to a solid boundary, or in the case of a periodic domain, to the first column and row respectively. In the latter case, and assuming your model domain is periodic, you can use the `FieldSet.add_periodic_halo` method to make sure particles can be correctly interpolated in the last zonal/meridional cells.\n", - "\n", - "Parcels can take care of loading C-grid data for you, through the general `FieldSet.from_c_grid_dataset` method (which takes a `gridindexingtype` argument), and the model-specific methods `FieldSet.from_nemo` and `FieldSet.from_mitgcm`.\n", - "\n", - "The only information that Parcels needs is the location of the 4 corners of the cell, which are called the $f$-node. Those are the ones provided by `U.grid` (and are equal to the ones in `V.grid`). Parcels does not need the exact location of the `U` and `V` nodes, because in C-grids, `U` and `V` nodes are always located in the same position relative to the $f$-node.\n", - "\n", - "Importantly, the interpolation of velocities on Arakawa C-grids is done in barycentric coordinates: $\\xi$, $\\eta$ and $\\zeta$ instead of longitude, latitude and depth. These coordinates are always between 0 and 1, measured from the corner of the grid cell. This is more accurate than simple linear interpolation on curvilinear grids.\n", - "\n", - "When calling `FieldSet.from_c_grid_dataset()` or a method that is based on it like `FieldSet.from_nemo()`, Parcels automatically sets the interpolation method for the `U`, `V` and `W` `Fields` to `cgrid_velocity`. With this interpolation method, the velocity interpolation follows the description in [Section 2.1.2 of Delandmeter and Van Sebille (2019)](https://www.geosci-model-dev.net/12/3571/2019/gmd-12-3571-2019.html).\n", - "\n", - "#### NEMO Example\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import warnings\n", - "from glob import glob\n", - "from os import path\n", - "\n", - "import numpy as np\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's see how this works. We'll use the NemoNorthSeaORCA025-N006_data data, which is on an Arakawa C-grid. If we create the `FieldSet` using the coordinates in the U, V and W files, we get an Error as seen below:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "raises-exception" - ] - }, - "outputs": [], - "source": [ - "example_dataset_folder = parcels.download_example_dataset(\n", - " \"NemoNorthSeaORCA025-N006_data\"\n", - ")\n", - "ufiles = sorted(glob(f\"{example_dataset_folder}/ORCA*U.nc\"))\n", - "vfiles = sorted(glob(f\"{example_dataset_folder}/ORCA*V.nc\"))\n", - "wfiles = sorted(glob(f\"{example_dataset_folder}/ORCA*W.nc\"))\n", - "\n", - "filenames = {\"U\": ufiles, \"V\": vfiles, \"W\": wfiles}\n", - "variables = {\"U\": \"uo\", \"V\": \"vo\", \"W\": \"wo\"}\n", - "dimensions = {\n", - " \"U\": {\n", - " \"lon\": \"nav_lon\",\n", - " \"lat\": \"nav_lat\",\n", - " \"depth\": \"depthu\",\n", - " \"time\": \"time_counter\",\n", - " },\n", - " \"V\": {\n", - " \"lon\": \"nav_lon\",\n", - " \"lat\": \"nav_lat\",\n", - " \"depth\": \"depthv\",\n", - " \"time\": \"time_counter\",\n", - " },\n", - " \"W\": {\n", - " \"lon\": \"nav_lon\",\n", - " \"lat\": \"nav_lat\",\n", - " \"depth\": \"depthw\",\n", - " \"time\": \"time_counter\",\n", - " },\n", - "}\n", - "\n", - "fieldset = parcels.FieldSet.from_nemo(filenames, variables, dimensions)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can still load the data this way, if we use the `FieldSet.from_netcdf()` method. But because this method assumes the data is on an Arakawa-A grid, **this will mean wrong interpolation**.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldsetA = parcels.FieldSet.from_netcdf(filenames, variables, dimensions)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Instead, we need to provide the coordinates of the $f$-points. In NEMO, these are called `glamf`, `gphif` and `depthw` (in MITgcm, these would be called `XG`, `YG` and `Zl`). The first two are in the `coordinates.nc` file, the last one is in the `W` file.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "mesh_mask = f\"{example_dataset_folder}/coordinates.nc\"\n", - "\n", - "filenames = {\n", - " \"U\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": ufiles},\n", - " \"V\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": vfiles},\n", - " \"W\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": wfiles},\n", - "}\n", - "\n", - "# Note that all variables need the same dimensions in a C-Grid\n", - "c_grid_dimensions = {\n", - " \"lon\": \"glamf\",\n", - " \"lat\": \"gphif\",\n", - " \"depth\": \"depthw\",\n", - " \"time\": \"time_counter\",\n", - "}\n", - "dimensions = {\n", - " \"U\": c_grid_dimensions,\n", - " \"V\": c_grid_dimensions,\n", - " \"W\": c_grid_dimensions,\n", - "}\n", - "\n", - "with warnings.catch_warnings():\n", - " warnings.simplefilter(\"ignore\", parcels.FileWarning)\n", - " fieldsetC = parcels.FieldSet.from_nemo(filenames, variables, dimensions)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note by the way, that we used `warnings.catch_warnings()` with `warnings.simplefilter(\"ignore\", parcels.FileWarning)` to wrap the `FieldSet.from_nemo()` call above. This is to silence an expected warning because the time dimension in the `coordinates.nc` file can't be decoded by `xarray`.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can plot the different grid points to see that indeed, the longitude and latitude of `fieldsetA.U` and `fieldsetA.V` are different.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from mpl_toolkits.mplot3d import Axes3D # noqa\n", - "\n", - "fig, ax = plt.subplots()\n", - "nind = 3\n", - "ax1 = ax.plot(\n", - " fieldsetA.U.grid.lon[:nind, :nind],\n", - " fieldsetA.U.grid.lat[:nind, :nind],\n", - " \".r\",\n", - " label=\"U points assuming A-grid\",\n", - ")\n", - "ax2 = ax.plot(\n", - " fieldsetA.V.grid.lon[:nind, :nind],\n", - " fieldsetA.V.grid.lat[:nind, :nind],\n", - " \".b\",\n", - " label=\"V points assuming A-grid\",\n", - ")\n", - "\n", - "ax3 = ax.plot(\n", - " fieldsetC.U.grid.lon[:nind, :nind],\n", - " fieldsetC.U.grid.lat[:nind, :nind],\n", - " \".k\",\n", - " label=\"C-grid points\",\n", - ")\n", - "\n", - "ax.legend(handles=[ax1[0], ax2[0], ax3[0]], loc=\"center left\", bbox_to_anchor=(1, 0.5))\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Further information about the NEMO C-grids is available in [the NEMO 3D tutorial](https://docs.oceanparcels.org/en/latest/examples/tutorial_nemo_3D.html).\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Arakawa B-grids\n", - "\n", - "Interpolation for Arakawa B-grids is detailed in [Section 2.1.3 of Delandmeter and Van Sebille (2019)](https://www.geosci-model-dev.net/12/3571/2019/gmd-12-3571-2019.html). Again, the dimensions that need to be provided are those of the barycentric cell edges `(i, j, k)`.\n", - "\n", - "To load B-grid data, you can use the method `FieldSet.from_b_grid_dataset`.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3D C- and B-grids\n", - "\n", - "For 3D C-grids and B-grids, the idea is the same. It is important to follow the indexing notation, which is defined in Parcels and in [Delandmeter and Van Sebille (2019)](https://www.geosci-model-dev.net/12/3571/2019/gmd-12-3571-2019.html). In 3D C-grids, the vertical (`W`) velocities are at the top and bottom. Note that in the vertical, the bottom velocity is often omitted, since a no-flux boundary conditions implies a zero vertical velocity at the ocean floor. That means that the vertical dimension of `W` often corresponds to the amount of vertical levels.\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.4" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/documentation_stuck_particles.ipynb b/docs/user_guide/examples_v3/documentation_stuck_particles.ipynb deleted file mode 100644 index 880835f96b..0000000000 --- a/docs/user_guide/examples_v3/documentation_stuck_particles.ipynb +++ /dev/null @@ -1,1322 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "slideshow": { - "slide_type": "slide" - } - }, - "source": [ - "# Stuck particles\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "slideshow": { - "slide_type": "slide" - } - }, - "source": [ - "In some Parcels simulations, particles end up moving onto parts of the grid where the velocity field is not defined, such as for example onto land. In this tutorial we look at how this depends on the grid structure.\n", - "\n", - "**Short conclusion: Particles can get stuck on Arakawa A grids and B grids but should not get stuck on C grids.**\n", - "\n", - "This tutorial first looks at how velocity fields are structured and what that means for where the ocean-land boundaries are located. Then we look at how particles may end up getting 'stuck' on these boundaries. How to make these particles become 'unstuck' again in Parcels will be discussed in another notebook.\n", - "\n", - "- [Introduction](#Introduction)\n", - "- [A grid interpolated velocity fields - SMOC](#1.-A-grids)\n", - "- [C grid numerical model - NEMO](#2.-C-grids)\n", - "- [B grids](#3.-B-grids)\n", - "- [Diffusion](#4.-Diffusion)\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Introduction\n", - "\n", - "Parcels can handle several different types of velocity fields, which makes it widely applicable. This also means that the underlying code and therefore the accuracy of the calculated trajectories can differ, depending on the velocity data input. Even when Parcels runs smoothly with the velocity fields you use, it is good to realize how your velocity fields are structured and how those velocities are generated in the first place.\n", - "\n", - "Horizontal velocity data may be structured on a staggered (Arakawa-C) or unstaggered grid (Arakawa-A). The implementation of these grids is covered in [this tutorial](documentation_indexing.ipynb).\n", - "\n", - "The source of your velocity data will influence how accurate and physically consistent parcels can calculate trajectories. Common sources are **numerical models and data assimilation products**, **interpolations** of those products or **discrete observations**. The staggering of variables determines how the ocean-land boundaries are defined in models and how their boundary conditions can be satisfied. The Parcels interpolation scheme differs per grid configuration and makes some underlying assumptions that determine the trajectory within a grid cell. Whether the source of your velocity data has physically consistent boundary conditions and how well these align with the assumptions made in Parcels determines how accurately the trajectories move within grid cells. This is especially important near ocean-land boundaries, where particles may end up 'stuck' on land.\n", - "\n", - "Here we will look at two examples of velocity fields in parcels. We visualize the structure of the velocity field, briefly discuss the Parcels implementation and look at how particles get stuck. Then we shortly discuss Arakawa B grids and additional sources of movement that may not respect the ocean-land boundary, such as particle diffusion using a random walk.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true, - "slideshow": { - "slide_type": "skip" - } - }, - "outputs": [], - "source": [ - "import math\n", - "from copy import copy\n", - "from datetime import timedelta\n", - "from glob import glob\n", - "\n", - "import cmocean\n", - "import matplotlib.gridspec as gridspec\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import xarray as xr\n", - "from IPython.display import Image\n", - "from matplotlib.colors import ListedColormap\n", - "from matplotlib.lines import Line2D\n", - "from scipy import interpolate\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "slideshow": { - "slide_type": "slide" - } - }, - "source": [ - "## 1. A grids\n", - "\n", - "Arakawa A grids are unstaggered grids where the velocities $u$, $v$ (and $w$), pressure and other tracers are defined at the same position (on so-called nodes). In numerical models, these nodes can be located **at the corner _or_ at the center of the grid cells**. This means that the cell boundaries, and therefore the solid-fluid boundaries can either be located at the nodes (**figure 1A**) or at 0.5 dx distance from the nodes (**figure 1B**) respectively.\n", - "\n", - "Many ocean models natively run on a C grid, because boundary conditions are easier to implement there (see [C grid](#2.-C-grids)). Sometimes, the C-grid output of these models is interpolated onto an A grid. This is the case for all(?) the data available from [Copernicus Marine Data Store](https://data.marine.copernicus.eu/products), which provide the data on a rectilinear A grid velocity field.\n", - "\n", - "To visualize this, in **figure 1** we show the nodes and cells of a coastal region. The ocean cells and nodes are in red and the land cells and nodes are in white.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "# Open dataset\n", - "SMOCfile = \"SMOC_20190704_R20190705.nc\"\n", - "flowdata_SMOC = xr.open_dataset(SMOCfile)\n", - "\n", - "# Define meshgrid coordinates to plot velocity field with matplotlib pcolormesh\n", - "dlon = flowdata_SMOC[\"longitude\"][1] - flowdata_SMOC[\"longitude\"][0]\n", - "dlat = flowdata_SMOC[\"latitude\"][1] - flowdata_SMOC[\"latitude\"][0]\n", - "\n", - "# Outside corner coordinates - coordinates + 0.5 dx\n", - "x_outcorners, y_outcorners = np.meshgrid(\n", - " np.append(\n", - " (flowdata_SMOC[\"longitude\"] - 0.5 * dlon),\n", - " (flowdata_SMOC[\"longitude\"][-1] + 0.5 * dlon),\n", - " ),\n", - " np.append(\n", - " (flowdata_SMOC[\"latitude\"] - 0.5 * dlat),\n", - " (flowdata_SMOC[\"latitude\"][-1] + 0.5 * dlat),\n", - " ),\n", - ")\n", - "\n", - "# Inside corner coordinates - coordinates + 0.5 dx\n", - "# needed to plot cells between velocity field nodes\n", - "x_incorners, y_incorners = np.meshgrid(\n", - " (flowdata_SMOC[\"longitude\"] + 0.5 * dlon)[:-1],\n", - " (flowdata_SMOC[\"latitude\"] + 0.5 * dlat)[:-1],\n", - ")\n", - "\n", - "# Center coordinates\n", - "x_centers, y_centers = np.meshgrid(\n", - " flowdata_SMOC[\"longitude\"], flowdata_SMOC[\"latitude\"]\n", - ")\n", - "\n", - "# --------- Velocity fields ---------\n", - "\n", - "# Empty cells between coordinate nodes - essentially on inside corners\n", - "cells = np.zeros((len(flowdata_SMOC[\"latitude\"]), len(flowdata_SMOC[\"longitude\"])))\n", - "\n", - "# Masking the flowfield where U = NaN\n", - "umask = np.ma.masked_invalid(flowdata_SMOC[\"uo\"][0, 0])\n", - "\n", - "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", - "u_zeros = np.nan_to_num(flowdata_SMOC[\"uo\"][0, 0])\n", - "# Interpolate U\n", - "fu = interpolate.RectBivariateSpline(\n", - " flowdata_SMOC[\"latitude\"],\n", - " flowdata_SMOC[\"longitude\"],\n", - " u_zeros,\n", - " kx=1,\n", - " ky=1,\n", - ")\n", - "\n", - "# Velocity field interpolated on the inside corners\n", - "u_corners = fu(y_incorners[:, 0], x_incorners[0, :])\n", - "\n", - "# Masking the interpolated flowfield where U = 0\n", - "udmask = np.ma.masked_values(u_corners, 0)\n", - "\n", - "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", - "v_zeros = np.nan_to_num(flowdata_SMOC[\"vo\"][0, 0])\n", - "\n", - "# Interpolate V\n", - "fv = interpolate.RectBivariateSpline(\n", - " flowdata_SMOC[\"latitude\"], flowdata_SMOC[\"longitude\"], v_zeros\n", - ")\n", - "\n", - "# --------- Plotting domain ---------\n", - "lonminx = 2100\n", - "lonmaxx = 2500\n", - "latminx = 1300\n", - "latmaxx = 1750\n", - "\n", - "# Select velocity domain to plot\n", - "SMOC_U = flowdata_SMOC[\"uo\"][0, 0, latminx:latmaxx, lonminx:lonmaxx].fillna(0)\n", - "SMOC_V = flowdata_SMOC[\"vo\"][0, 0, latminx:latmaxx, lonminx:lonmaxx].fillna(0)\n", - "\n", - "### Create seethrough colormap to show different grid interpretations\n", - "cmap = plt.get_cmap(\"Blues\")\n", - "my_cmap = cmap(np.arange(cmap.N))\n", - "my_cmap[:, -1] = 0 # set alpha to zero\n", - "my_cmap = ListedColormap(my_cmap)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15), constrained_layout=True)\n", - "fig.suptitle(\"Figure 1\", fontsize=16)\n", - "\n", - "ax1.set_xlim(4.6, 5.4)\n", - "ax1.set_ylim(52.7, 53.3)\n", - "ax1.set_xlabel(\"Longitude\")\n", - "ax1.set_ylabel(\"Latitude\")\n", - "ax1.set_title(\n", - " \"A) Node at corner cell - agreement with Parcels interpolation\",\n", - " fontsize=14,\n", - " fontweight=\"bold\",\n", - ")\n", - "ax1.pcolormesh(\n", - " x_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " udmask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"orange\",\n", - ")\n", - "ax1.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " c=SMOC_U,\n", - " cmap=\"cmo.balance_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax1.quiver(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " SMOC_U,\n", - " SMOC_V,\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=8,\n", - " color=\"w\",\n", - " edgecolor=\"k\",\n", - ")\n", - "\n", - "ax2.set_xlim(4.6, 5.4)\n", - "ax2.set_ylim(52.7, 53.3)\n", - "ax2.set_xlabel(\"Longitude\")\n", - "ax2.set_ylabel(\"Latitude\")\n", - "ax2.set_title(\n", - " \"B) Node at center cell - not according to Parcels interpolation\",\n", - " fontsize=14,\n", - " fontweight=\"bold\",\n", - ")\n", - "ax2.pcolormesh(\n", - " x_outcorners[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_outcorners[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " umask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"k\",\n", - " linewidth=1,\n", - ")\n", - "ax2.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " c=SMOC_U,\n", - " cmap=\"cmo.balance_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax2.quiver(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " SMOC_U,\n", - " SMOC_V,\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=8,\n", - " color=\"w\",\n", - " edgecolor=\"k\",\n", - ")\n", - "\n", - "ax3.set_xlim(4.6, 5.4)\n", - "ax3.set_ylim(52.7, 53.3)\n", - "ax3.set_xlabel(\"Longitude\")\n", - "ax3.set_ylabel(\"Latitude\")\n", - "ax3.set_title(\n", - " \"C) Different boundaries - Parcels interpolates according to orange grid\",\n", - " fontsize=14,\n", - " fontweight=\"bold\",\n", - ")\n", - "ax3.pcolormesh(\n", - " x_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " udmask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"orange\",\n", - ")\n", - "ax3.pcolormesh(\n", - " x_outcorners[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_outcorners[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " cells[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=my_cmap,\n", - " edgecolors=\"black\",\n", - ")\n", - "ax3.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " c=SMOC_U,\n", - " cmap=\"cmo.balance_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax3.quiver(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " SMOC_U,\n", - " SMOC_V,\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=8,\n", - " color=\"w\",\n", - " edgecolor=\"k\",\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Figure 1** shows how _land_ grid points and boundaries can be interpreted, depending on whether you assume a grid node is at the center or corner of a cell. When they are at the corner of a cell, boundaries are interpreted as the edges between two nodes. This is especially visible in the upper center of the figures, where the white nodes with surrounding ocean can be assumed to only have a line of _land_ between them (**figure 1A**), or as entire _land_ cells (**figure 1B**).\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.1 Parcels bilinear interpolation\n", - "\n", - "On Arakawa A grids, Parcels uses a simple bilinear interpolation to find the particle velocity at a specific location in a cell. It finds the four nearest velocity components in a 2D field and interpolates between those. This means that the velocity field is essentially divided into cells by parcels as in **figure 1A**. The boundaries of cells in this case are located between the nodes of the velocity field and therefore the ocean-land boundary lies in between the nodes of the land. Since both velocity components are defined at all four corner nodes, they equally persist in the limit toward the boundary as they go to zero.\n", - "\n", - "In **figure 2**, we zoom on a cell with the velocities at the nodes and an interpolation point shown in cyan. You can see how boundaries are 'interpreted' by Parcels' bilinear interpolation. Given a small enough `dt`, particle trajectories will not cross any lines between two white (land) nodes. This means that particle trajectories will not move onto land. They can however keep moving toward the boundary and essentially get stuck once they come really close, as we will see in the following section.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(8, 6))\n", - "fig.suptitle(\"Figure 2\", fontsize=16)\n", - "ax = plt.axes()\n", - "\n", - "ax.set_xlim(5.0, 5.18)\n", - "ax.set_ylim(52.9, 53.02)\n", - "ax.set_xlabel(\"Longitude\")\n", - "ax.set_ylabel(\"Latitude\")\n", - "ax.set_title(\"Bilinear interpolation\", fontsize=14, fontweight=\"bold\")\n", - "ax.pcolormesh(\n", - " x_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " udmask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"orange\",\n", - ")\n", - "ax.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " c=SMOC_U,\n", - " cmap=\"cmo.balance_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax.quiver(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " SMOC_U,\n", - " SMOC_V,\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=3,\n", - " color=\"w\",\n", - " width=0.01,\n", - ")\n", - "\n", - "plon = 5.12\n", - "plat = 52.93\n", - "pU = fu(plon, plat)\n", - "pV = fv(plon, plat)\n", - "ax.scatter(plon, plat, s=50, color=\"cyan\", marker=\"h\")\n", - "ax.quiver(plon, plat, pU, pV, angles=\"xy\", scale_units=\"xy\", scale=3, color=\"w\")\n", - "\n", - "color_land = copy(plt.get_cmap(\"Reds\"))(0)\n", - "color_ocean = copy(plt.get_cmap(\"Reds\"))(256)\n", - "\n", - "custom_lines = [\n", - " Line2D([0], [0], c=\"cyan\", marker=\"h\", markersize=10, lw=0),\n", - " Line2D(\n", - " [0], [0], c=color_ocean, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - "]\n", - "ax.legend(\n", - " custom_lines,\n", - " [\"Particle\", \"Ocean nodes\", \"Land nodes\"],\n", - " bbox_to_anchor=(1.05, 1),\n", - " loc=\"upper left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.2 How do particles get stuck?\n", - "\n", - "Here we create a parcels simulation of trajectories of particles released near and on the _land_ cells in the velocity field and see how the bilinear interpolation causes particles to get stuck.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": false - }, - "outputs": [], - "source": [ - "SMOCfile = \"SMOC_201907*.nc\"\n", - "filenames = {\"U\": SMOCfile, \"V\": SMOCfile}\n", - "\n", - "variables = {\"U\": \"uo\", \"V\": \"vo\"}\n", - "\n", - "dims = {\"lon\": \"longitude\", \"lat\": \"latitude\", \"depth\": \"depth\", \"time\": \"time\"}\n", - "dimensions = {\"U\": dims, \"V\": dims}\n", - "\n", - "fieldset = parcels.FieldSet.from_netcdf(filenames, variables, dimensions)\n", - "\n", - "npart = 3 # number of particles to be released\n", - "lon = np.linspace(7, 7.2, npart, dtype=np.float32)\n", - "lat = np.linspace(53.45, 53.6, npart, dtype=np.float32)\n", - "lons, lats = np.meshgrid(lon, lat)\n", - "time = np.zeros(lons.size)\n", - "\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=parcels.Particle, lon=lons, lat=lats, time=time\n", - ")\n", - "\n", - "kernels = pset.Kernel(parcels.kernels.AdvectionRK4)\n", - "\n", - "output_file = pset.ParticleFile(name=\"SMOC.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "pset.execute(\n", - " kernels,\n", - " runtime=timedelta(hours=119),\n", - " dt=timedelta(minutes=12),\n", - " output_file=output_file,\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Particles moving toward the boundary will keep slowing down as long as the cross-boundary component of one of the 4 nearest velocity vectors is directed toward the boundary. The distance travelled each `dt` will decrease to unrealistic values in the absence of local forces directing the flow along the boundary. If we define a particle to be stuck when it moves less than approximately 1 meter every hour, we can see how particles get stuck on the model boundary.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "ds_SMOC = xr.open_zarr(\"SMOC.zarr\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "# Calculating when a particle is stuck\n", - "pdx = np.diff(ds_SMOC[\"lon\"], axis=1, prepend=0)\n", - "pdy = np.diff(ds_SMOC[\"lat\"], axis=1, prepend=0)\n", - "\n", - "# approximation of the distance travelled\n", - "distance = np.sqrt(np.square(pdx) + np.square(pdy))\n", - "stuck = distance < 1e-5 # [degrees] 1e-5 degrees ~~ 1 m in dt = 1 hour." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(18, 7))\n", - "fig.suptitle(\"Figure 3. SMOC A-grid\", fontsize=16)\n", - "gs = gridspec.GridSpec(ncols=2, nrows=1, figure=fig)\n", - "\n", - "ax1 = fig.add_subplot(gs[0, 0])\n", - "ax1.set_ylabel(\"Latitude [degrees]\")\n", - "ax1.set_xlabel(\"Longitude [degrees]\")\n", - "ax1.set_title(\"A) Parcels bilinear interpolation\", fontsize=14, fontweight=\"bold\")\n", - "ax1.set_xlim(6.9, 7.5)\n", - "ax1.set_ylim(53.4, 53.8)\n", - "\n", - "ax1.pcolormesh(\n", - " x_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " udmask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"orange\",\n", - ")\n", - "ax1.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " c=SMOC_U,\n", - " cmap=\"cmo.balance_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax1.plot(ds_SMOC[\"lon\"].T, ds_SMOC[\"lat\"].T, linewidth=3, zorder=1)\n", - "ax1.scatter(ds_SMOC[\"lon\"], ds_SMOC[\"lat\"], c=stuck, cmap=\"viridis_r\", zorder=2)\n", - "\n", - "ax2 = fig.add_subplot(gs[0, 1])\n", - "ax2.set_ylabel(\"Latitude [degrees]\")\n", - "ax2.set_xlabel(\"Longitude [degrees]\")\n", - "ax2.set_title(\"B) Incompatible A grid interpretation\", fontsize=14, fontweight=\"bold\")\n", - "ax2.set_xlim(6.9, 7.5)\n", - "ax2.set_ylim(53.4, 53.8)\n", - "\n", - "ax2.pcolormesh(\n", - " x_outcorners[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_outcorners[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " umask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"k\",\n", - " linewidth=1,\n", - ")\n", - "ax2.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " c=SMOC_U,\n", - " cmap=\"cmo.balance_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax2.plot(ds_SMOC[\"lon\"].T, ds_SMOC[\"lat\"].T, linewidth=3, zorder=1)\n", - "ax2.scatter(ds_SMOC[\"lon\"], ds_SMOC[\"lat\"], c=stuck, cmap=\"viridis_r\", zorder=2)\n", - "\n", - "color_stuck = copy(plt.get_cmap(\"viridis\"))(0)\n", - "color_moving = copy(plt.get_cmap(\"viridis\"))(256)\n", - "custom_lines = [\n", - " Line2D([0], [0], c=color_stuck, marker=\"o\", markersize=10),\n", - " Line2D([0], [0], c=color_moving, marker=\"o\", markersize=10),\n", - "]\n", - "ax2.legend(\n", - " custom_lines,\n", - " [\"Stuck\", \"Moving\"],\n", - " bbox_to_anchor=(1.05, 1),\n", - " loc=\"upper left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In **figure 3A** you can see how particles released in a 3x3 grid keep moving toward the boundary between two _land_ nodes. The ratio of the $u$ and $v$ components stays at a similar value due to both being linearly interpolated to zero at the boundary. Note that the interpretation of nodes at the center of grid cells (**figure 3B**) is clearly incompatible with Parcels interpolation.\n", - "\n", - "If we look at the distance travelled each timestep (**figure 4**), we can see that the particles take smaller and smaller steps as they approach the ocean-land boundary. At this scale, a bilinear interpolation of the model results in unrealistic behaviour.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(8, 6))\n", - "fig.suptitle(\"Figure 4. Tolerance for getting stuck = red line\", fontsize=16)\n", - "ax = plt.axes()\n", - "\n", - "ax.plot(distance.T, color=\"b\")\n", - "ax.hlines(1e-5, 0, 130, color=\"r\")\n", - "ax.set_ylabel(\"Approximate distance travelled [degrees]\")\n", - "ax.set_xlabel(\"Timestep\")\n", - "ax.set_ylim(-0.00002, 0.0008)\n", - "ax.set_xlim(-10, 130)\n", - "custom_lines = [Line2D([0], [0], c=\"b\"), Line2D([0], [0], c=\"r\")]\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "slideshow": { - "slide_type": "slide" - } - }, - "source": [ - "## 2. C grids\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "On staggered grids, different types of boundary conditions can be satisfied simultaneously. On a C-grid, the velocities are defined on the cell-edges normal to the velocity-component and pressure, temperature and tracers are defined at the cell centers. This way, a [Dirichlet boundary condition](https://en.wikipedia.org/wiki/Dirichlet_boundary_condition) can be used for the velocities, while a [Neumann boundary condition](https://en.wikipedia.org/wiki/Neumann_boundary_condition) can be satisfied for the gradient of pressure.\n", - "\n", - "Here we investigate how Parcels interprets the the boundaries on a C grid. For background information, see [Delanmeter & Van Sebille (2019)](https://gmd.copernicus.org/articles/12/3571/2019/). First we show how the velocities are staggered and how the velocity input necessary to create a `FieldSet` results in the definition of boundaries in Parcels. This example uses a NEMO dataset but many of the relevant C grid assumptions are similar in other models, such as MITgcm.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true, - "slideshow": { - "slide_type": "skip" - } - }, - "outputs": [], - "source": [ - "example_dataset_folder = parcels.download_example_dataset(\n", - " \"NemoNorthSeaORCA025-N006_data\"\n", - ")\n", - "cufields = xr.open_dataset(f\"{example_dataset_folder}/ORCA025-N06_20000104d05U.nc\")\n", - "cvfields = xr.open_dataset(f\"{example_dataset_folder}/ORCA025-N06_20000104d05V.nc\")\n", - "\n", - "xu_corners, yu_corners = np.meshgrid(\n", - " np.arange(cufields[\"x\"].values[0], cufields[\"x\"].values[-1] + 1, 1),\n", - " np.arange(cufields[\"y\"].values[0] - 0.5, cufields[\"y\"].values[-1] + 0.5, 1),\n", - ")\n", - "xv_corners, yv_corners = np.meshgrid(\n", - " np.arange(cvfields[\"x\"].values[0] - 0.5, cvfields[\"x\"].values[-1] + 0.5, 1),\n", - " np.arange(cvfields[\"y\"].values[0], cvfields[\"y\"].values[-1] + 1, 1),\n", - ")\n", - "cx_centers, cy_centers = np.meshgrid(\n", - " np.arange(cvfields[\"x\"].values[0] - 0.5, cvfields[\"x\"].values[-1] + 1.5, 1),\n", - " np.arange(cvfields[\"y\"].values[0] - 0.5, cvfields[\"y\"].values[-1] + 1.5, 1),\n", - ")\n", - "fx_corners, fy_corners = np.meshgrid(\n", - " np.arange(cufields[\"x\"].values[0] - 1, cufields[\"x\"].values[-1] + 1, 1),\n", - " np.arange(cufields[\"y\"].values[0] - 1, cufields[\"y\"].values[-1] + 1, 1),\n", - ")\n", - "c_cells = np.zeros((len(cufields[\"y\"]), len(cufields[\"x\"])))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "# Velocity field with NaN -> zero to be able to use in RectBivariateSpline\n", - "cu_zeros = np.nan_to_num(cufields[\"uos\"][0])\n", - "f = interpolate.RectBivariateSpline(\n", - " yu_corners[:, 0], xu_corners[0, :], cu_zeros, kx=1, ky=1\n", - ")\n", - "\n", - "# Velocity field interpolated on the T-points - center\n", - "cu_centers = f(cy_centers[:-1, 0], cx_centers[0, :-1])\n", - "\n", - "# Masking the interpolated flowfield where U = 0\n", - "cudmask = np.ma.masked_values(cu_centers, 0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true, - "slideshow": { - "slide_type": "subslide" - } - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(8, 6))\n", - "fig.suptitle(\"Figure 5 - C grid structure\", fontsize=16)\n", - "ax1 = plt.axes()\n", - "\n", - "ax1.set_xlim(105, 112)\n", - "ax1.set_ylim(50, 54)\n", - "ax1.pcolormesh(\n", - " fx_corners, fy_corners, cudmask, cmap=\"Blues\", edgecolors=\"k\", linewidth=1\n", - ")\n", - "ax1.scatter(\n", - " xu_corners,\n", - " yu_corners,\n", - " s=80,\n", - " c=cufields[\"uos\"][0],\n", - " cmap=\"seismic\",\n", - " vmin=-0.1,\n", - " vmax=0.1,\n", - " edgecolor=\"k\",\n", - " label=\"U\",\n", - ")\n", - "ax1.scatter(\n", - " xv_corners,\n", - " yv_corners,\n", - " s=80,\n", - " c=cvfields[\"vos\"][0],\n", - " cmap=\"PRGn\",\n", - " vmin=-0.1,\n", - " vmax=0.1,\n", - " edgecolor=\"k\",\n", - " label=\"V\",\n", - ")\n", - "ax1.scatter(cx_centers, cy_centers, s=80, c=\"orange\", edgecolor=\"k\", label=\"T\")\n", - "ax1.quiver(\n", - " xu_corners,\n", - " yu_corners,\n", - " cufields[\"uos\"][0],\n", - " np.zeros(xu_corners.shape),\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=0.1,\n", - " width=0.007,\n", - ")\n", - "ax1.quiver(\n", - " xv_corners,\n", - " yv_corners,\n", - " np.zeros(xv_corners.shape),\n", - " cvfields[\"vos\"][0],\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=0.3,\n", - " width=0.007,\n", - ")\n", - "\n", - "custom_lines = [\n", - " Line2D([0], [0], marker=\"o\", color=\"r\", lw=0),\n", - " Line2D([0], [0], marker=\"o\", color=\"g\", lw=0),\n", - " Line2D([0], [0], marker=\"o\", color=\"orange\", lw=0),\n", - "]\n", - "\n", - "ax1.legend(\n", - " custom_lines,\n", - " [\"U\", \"V\", \"T\"],\n", - " bbox_to_anchor=(1.05, 1),\n", - " loc=\"upper left\",\n", - " borderaxespad=0.0,\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In **figure 5** you can see how a boundary can be traced along the cell edges, where the velocity is zero, because the _normal_ velocities are defined at the edges in a C-grid. This ensures that the most important boundary condition in many models is satisfied: the normal-component of velocity is zero at the boundary. Within a cell, Parcels interpolates the velocities on a C-grid linearly in the direction of that component and constant throughout a cell in the other directions. This means that the cross-boundary component is linearly interpolated to zero at the boundary and the along-boundary component is constant towards the boundary. Note that this is the same interpolation as used in the 'Analytical Advection' Scheme of e.g. TRACMASS and Ariane, see also [this tutorial](https://docs.oceanparcels.org/en/latest/examples/tutorial_analyticaladvection.html).\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.1 Consistency with NEMO configuration\n", - "\n", - "Here we look at how the lateral boundary conditions are implemented in NEMO. This is documented [here](https://www.nemo-ocean.eu/doc/node58.html).\n", - "\n", - "The cross-boundary component of velocity, defined at the cell faces where the boundary is defined, is set to zero at all boundaries. See the figure below. This corresponds exactly with the velocity field in a Parcels `FieldSet` shown in **figure 5**.\n", - "\n", - "\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "There are different options for the along-boundary velocity in NEMO: a free-slip condition, a partial-slip condition and a no-slip condition. Since the tangential velocity is not defined at the boundary, this boundary condition is defined by a Neumann boundary condition: the normal derivative of the along-boundary velocity is specified. This derivative is schematically represented by a \"ghost\" velocity on the adjacent land node. The specified derivative is equivalent to what would result from the central difference between the along-boundary velocity at the nearest ocean cell and this \"ghost\" velocity. The type of boundary condition defines the direction and magnitude of this \"ghost\" velocity relative to the along-boundary velocity in the fluid domain. In Parcels, these \"ghost\" velocities may be used to determine how the velocity should be interpolated near the coast. By default, parcels interpolates piecewise-constant in the direction normal to the velocity component. This means that the along-boundary velocity is the same for any distance away from the boundary and therefore equivalent to the free-slip boundary condition shown in subfigure **(a)** below.\n", - "\n", - "\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.2 Particle trajectories near the boundary in a C grid\n", - "\n", - "Now let's see how particles move near the boundary in a `FieldSet` derived from the mass-conserving NEMO C grid.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "example_dataset_folder = parcels.download_example_dataset(\n", - " \"NemoNorthSeaORCA025-N006_data\"\n", - ")\n", - "ufiles = sorted(glob(f\"{example_dataset_folder}/ORCA*U.nc\"))\n", - "vfiles = sorted(glob(f\"{example_dataset_folder}/ORCA*V.nc\"))\n", - "wfiles = sorted(glob(f\"{example_dataset_folder}/ORCA*W.nc\"))\n", - "mesh_mask = f\"{example_dataset_folder}/coordinates.nc\"\n", - "\n", - "coords = xr.open_dataset(mesh_mask, decode_times=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "filenames = {\n", - " \"U\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": ufiles},\n", - " \"V\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": vfiles},\n", - " \"W\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": wfiles},\n", - "}\n", - "\n", - "variables = {\"U\": \"uo\", \"V\": \"vo\", \"W\": \"wo\"}\n", - "dimensions = {\n", - " \"U\": {\"lon\": \"glamf\", \"lat\": \"gphif\", \"depth\": \"depthw\", \"time\": \"time_counter\"},\n", - " \"V\": {\"lon\": \"glamf\", \"lat\": \"gphif\", \"depth\": \"depthw\", \"time\": \"time_counter\"},\n", - " \"W\": {\"lon\": \"glamf\", \"lat\": \"gphif\", \"depth\": \"depthw\", \"time\": \"time_counter\"},\n", - "}\n", - "\n", - "fieldset = parcels.FieldSet.from_nemo(filenames, variables, dimensions)\n", - "\n", - "npart = 10 # number of particles to be released\n", - "lon = np.linspace(3, 4, npart, dtype=np.float32)\n", - "lat = 51.5 * np.ones(npart)\n", - "time = np.zeros(npart)\n", - "\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=parcels.Particle, lon=lon, lat=lat, time=time\n", - ")\n", - "\n", - "output_file = pset.ParticleFile(name=\"Cgrid-stuck.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "pset.execute(\n", - " parcels.kernels.AdvectionRK4,\n", - " runtime=timedelta(days=10),\n", - " dt=timedelta(minutes=5),\n", - " output_file=output_file,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "data_NEMO = xr.open_zarr(\"Cgrid-stuck.zarr\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "pdx = np.diff(data_NEMO[\"lon\"], axis=1, prepend=0)\n", - "pdy = np.diff(data_NEMO[\"lat\"], axis=1, prepend=0)\n", - "distance = np.sqrt(np.square(pdx) + np.square(pdy))\n", - "stuck = distance < (1e-5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6), constrained_layout=True)\n", - "fig.suptitle(\n", - " \"Figure 6. - NEMO model movement along solid cells in a C grid\", fontsize=16\n", - ")\n", - "\n", - "ax1.set_ylabel(\"Latitude [degrees]\")\n", - "ax1.set_xlabel(\"Longitude [degrees]\")\n", - "ax1.set_title(\"North Sea Domain\")\n", - "ax1.set_xlim(-5, 5)\n", - "ax1.set_ylim(48, 58)\n", - "\n", - "ax1.pcolormesh(\n", - " coords[\"glamf\"][0],\n", - " coords[\"gphif\"][0],\n", - " cvfields[\"vo\"][0, 0, 1:, 1:],\n", - " cmap=\"RdBu\",\n", - " vmin=-0.1,\n", - " vmax=0.1,\n", - ")\n", - "ax1.plot(data_NEMO[\"lon\"].T, data_NEMO[\"lat\"].T, linewidth=3, zorder=1)\n", - "ax1.scatter(data_NEMO[\"lon\"], data_NEMO[\"lat\"], c=stuck, cmap=\"viridis_r\", zorder=2)\n", - "\n", - "ax2.set_ylabel(\"Latitude [degrees]\")\n", - "ax2.set_xlabel(\"Longitude [degrees]\")\n", - "ax2.set_title(\"Particle getting stuck\")\n", - "ax2.set_xlim(2.9, 4.1)\n", - "ax2.set_ylim(51.4, 52.2)\n", - "\n", - "ax2.pcolormesh(\n", - " coords[\"glamf\"][0],\n", - " coords[\"gphif\"][0],\n", - " cvfields[\"vo\"][0, 0, 1:, 1:],\n", - " cmap=\"RdBu\",\n", - " vmin=-0.1,\n", - " vmax=0.1,\n", - " edgecolors=\"k\",\n", - " linewidth=1,\n", - ")\n", - "ax2.plot(data_NEMO[\"lon\"].T, data_NEMO[\"lat\"].T, linewidth=3, zorder=1)\n", - "ax2.scatter(\n", - " data_NEMO[\"lon\"],\n", - " data_NEMO[\"lat\"],\n", - " c=stuck,\n", - " s=30,\n", - " cmap=\"viridis_r\",\n", - " alpha=0.3,\n", - " zorder=2,\n", - ")\n", - "\n", - "color_stuck = copy(plt.get_cmap(\"viridis\"))(0)\n", - "color_moving = copy(plt.get_cmap(\"viridis\"))(256)\n", - "custom_lines = [\n", - " Line2D([0], [0], c=color_stuck, marker=\"o\", markersize=10),\n", - " Line2D([0], [0], c=color_moving, marker=\"o\", markersize=10),\n", - "]\n", - "ax2.legend(\n", - " custom_lines,\n", - " [\"Stuck\", \"Moving\"],\n", - " bbox_to_anchor=(1.05, 1),\n", - " loc=\"upper left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "As we can see in **figure 6**, the particles that start in the ocean move along the boundary, since the cross-boundary component goes to zero, but the along-boundary component is equal to the value away from the coast.\n", - "\n", - "**Figure 8** shows how the distance travelled does not go to zero as the particles move close to the model boundary, as the along-boundary component does not go to zero.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(8, 6))\n", - "fig.suptitle(\"Figure 8. Tolerance for getting stuck = red line\", fontsize=16)\n", - "ax = plt.axes()\n", - "\n", - "ax.plot(distance.T, color=\"b\", zorder=1)\n", - "ax.hlines(1e-5, 0, 250, color=\"r\", zorder=2)\n", - "ax.set_ylabel(\"Approximate distance travelled [degrees]\")\n", - "ax.set_xlabel(\"Timestep\")\n", - "ax.set_ylim(-0.00002, 0.003)\n", - "ax.set_xlim(-10, 250)\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3. B grids\n", - "\n", - "On Arakawa B grids, $u$ and $v$ are at the same location, while $w$ and scalar variables like pressure are staggered. In 2 dimensional flow, Parcels therefore uses the same bilinear interpolation as for [A grids](#1.-A-grids) to find the horizontal velocity, since the $u$ and $v$ components are similarly collocated. This can cause particles to get stuck in the same way as on A grids.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(8, 6))\n", - "fig.suptitle(\"Figure 9. - B grid structure\", fontsize=16)\n", - "ax = plt.axes()\n", - "\n", - "ax.set_xlim(5.0, 5.18)\n", - "ax.set_ylim(52.9, 53.02)\n", - "ax.set_xlabel(\"Longitude\")\n", - "ax.set_ylabel(\"Latitude\")\n", - "ax.pcolormesh(\n", - " x_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " y_centers[latminx : latmaxx + 1, lonminx : lonmaxx + 1],\n", - " udmask.mask[latminx:latmaxx, lonminx:lonmaxx],\n", - " cmap=\"Reds_r\",\n", - " edgecolors=\"orange\",\n", - ")\n", - "ax.scatter(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " color=\"green\",\n", - " edgecolors=\"k\",\n", - ")\n", - "ax.scatter(\n", - " x_outcorners[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_outcorners[latminx:latmaxx, lonminx:lonmaxx],\n", - " s=100,\n", - " color=\"orange\",\n", - " edgecolors=\"k\",\n", - ")\n", - "ax.quiver(\n", - " x_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " y_centers[latminx:latmaxx, lonminx:lonmaxx],\n", - " SMOC_U,\n", - " SMOC_V,\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=10,\n", - " color=\"w\",\n", - " width=0.01,\n", - ")\n", - "\n", - "plon = 5.1\n", - "plat = 52.95\n", - "pU = fu(plon, plat)\n", - "pV = fv(plon, plat)\n", - "ax.scatter(plon, plat, s=50, color=\"cyan\", marker=\"h\")\n", - "ax.quiver(plon, plat, pU, pV, angles=\"xy\", scale_units=\"xy\", scale=10, color=\"w\")\n", - "\n", - "custom_lines = [\n", - " Line2D([0], [0], c=\"green\", marker=\"o\", markersize=10, lw=0),\n", - " Line2D([0], [0], c=\"orange\", marker=\"o\", markersize=10, lw=0),\n", - " Line2D([0], [0], c=\"cyan\", marker=\"h\", markersize=10, lw=0),\n", - "]\n", - "ax.legend(\n", - " custom_lines,\n", - " [\"Velocity points\", \"Scalar points\", \"Particle\"],\n", - " bbox_to_anchor=(1.05, 1),\n", - " loc=\"upper left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 4. Diffusion\n", - "\n", - "In many parcels simulations other motions are added. Examples are Brownian motion from a diffusivity field and Stokes drift based on a wind field. These sources of motion will not necessarily consider the same boundaries and, unless they are also based on a C grid, they may also result in particles getting stuck on land. To show how this happens, let us add a random walk to the advection in a C grid velocity field.\n", - "\n", - "This random walk can be added using a diffusion kernel, as documented in [this notebook](tutorial_diffusion.ipynb). Since the particles will move randomly through the domain, without awareness of the solid-fluid boundaries in the velocity field, we cannot define stuck particles as having moved less than a tolerance value and we will instead check whether particles find themselves on land or not. To do this, we sample a `landmask` field which denotes the f-nodes that are part of the solid.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "LandParticle = parcels.Particle.add_variable(\"on_land\")\n", - "\n", - "\n", - "def Sample_land(particle, fieldset, time):\n", - " particle.on_land = fieldset.landmask[\n", - " time, particle.depth, particle.lat, particle.lon\n", - " ]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "landmask = np.logical_or(\n", - " np.ma.masked_equal(cufields[\"uo\"][0, 0], 0.0).mask,\n", - " np.ma.masked_equal(cvfields[\"vo\"][0, 0], 0.0).mask,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "filenames = {\n", - " \"U\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": ufiles},\n", - " \"V\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": vfiles},\n", - " \"W\": {\"lon\": mesh_mask, \"lat\": mesh_mask, \"depth\": wfiles[0], \"data\": wfiles},\n", - "}\n", - "\n", - "variables = {\"U\": \"uo\", \"V\": \"vo\", \"W\": \"wo\"}\n", - "dimensions = {\n", - " \"U\": {\"lon\": \"glamf\", \"lat\": \"gphif\", \"depth\": \"depthw\", \"time\": \"time_counter\"},\n", - " \"V\": {\"lon\": \"glamf\", \"lat\": \"gphif\", \"depth\": \"depthw\", \"time\": \"time_counter\"},\n", - " \"W\": {\"lon\": \"glamf\", \"lat\": \"gphif\", \"depth\": \"depthw\", \"time\": \"time_counter\"},\n", - "}\n", - "\n", - "fieldset = parcels.FieldSet.from_nemo(filenames, variables, dimensions)\n", - "\n", - "fieldset.add_constant_field(\"Kh_zonal\", 5, mesh=\"spherical\")\n", - "fieldset.add_constant_field(\"Kh_meridional\", 5, mesh=\"spherical\")\n", - "# fieldset.add_constant('dres', 0.00005)\n", - "fieldset.add_field(\n", - " parcels.Field(\n", - " \"landmask\",\n", - " data=landmask,\n", - " lon=coords[\"glamf\"],\n", - " lat=coords[\"gphif\"],\n", - " mesh=\"spherical\",\n", - " )\n", - ")\n", - "\n", - "npart = 10 # number of particles to be released\n", - "lon = np.linspace(3, 4, npart, dtype=np.float32)\n", - "lat = 51.5 * np.ones(npart)\n", - "time = np.zeros(npart)\n", - "\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=LandParticle, lon=lon, lat=lat, time=time\n", - ")\n", - "\n", - "output_file = pset.ParticleFile(\n", - " name=\"Cgrid-diffusion.zarr\", outputdt=timedelta(hours=1)\n", - ")\n", - "pset.execute(\n", - " pset.Kernel(parcels.kernels.AdvectionRK4)\n", - " + pset.Kernel(parcels.kernels.DiffusionUniformKh)\n", - " + pset.Kernel(Sample_land),\n", - " runtime=timedelta(days=10),\n", - " dt=timedelta(minutes=5),\n", - " output_file=output_file,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "data_diff = xr.open_zarr(\"Cgrid-diffusion.zarr\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "stuck = data_diff[\"on_land\"] == 1" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "hide_input": true - }, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(14, 8))\n", - "fig.suptitle(\"Figure 10. Diffusion added to C grid advection\", fontsize=16)\n", - "ax = plt.axes()\n", - "\n", - "ax.set_ylabel(\"Latitude [degrees]\")\n", - "ax.set_xlabel(\"Longitude [degrees]\")\n", - "ax.set_title(\"Particle getting stuck\")\n", - "ax.set_xlim(2.9, 4.1)\n", - "ax.set_ylim(51.4, 52.2)\n", - "\n", - "ax.pcolormesh(\n", - " coords[\"glamf\"][0],\n", - " coords[\"gphif\"][0],\n", - " cvfields[\"vo\"][0, 0, 1:, 1:],\n", - " cmap=\"RdBu\",\n", - " vmin=-0.1,\n", - " vmax=0.1,\n", - " edgecolors=\"k\",\n", - " linewidth=1,\n", - ")\n", - "ax.plot(data_diff[\"lon\"].T, data_diff[\"lat\"].T, linewidth=3, zorder=1)\n", - "ax.scatter(\n", - " data_diff[\"lon\"],\n", - " data_diff[\"lat\"],\n", - " c=stuck,\n", - " s=30,\n", - " cmap=\"viridis_r\",\n", - " alpha=0.3,\n", - " zorder=2,\n", - ")\n", - "ax.scatter(\n", - " coords[\"glamf\"][0],\n", - " coords[\"gphif\"][0],\n", - " c=landmask,\n", - " cmap=\"Reds_r\",\n", - " s=100,\n", - " edgecolors=\"k\",\n", - " vmin=-1,\n", - " linewidth=0.5,\n", - ")\n", - "\n", - "color_stuck = copy(plt.get_cmap(\"viridis\"))(0)\n", - "color_moving = copy(plt.get_cmap(\"viridis\"))(256)\n", - "color_land = copy(plt.get_cmap(\"Reds\"))(0)\n", - "color_ocean = copy(plt.get_cmap(\"Reds\"))(128)\n", - "\n", - "custom_lines = [\n", - " Line2D([0], [0], c=color_stuck, marker=\"o\", markersize=10, lw=0),\n", - " Line2D([0], [0], c=color_moving, marker=\"o\", markersize=10, lw=0),\n", - " Line2D(\n", - " [0], [0], c=color_ocean, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - "]\n", - "ax.legend(\n", - " custom_lines,\n", - " [\"On land\", \"In ocean\", \"Landmask ocean\", \"Landmask land\"],\n", - " bbox_to_anchor=(1.05, 1),\n", - " loc=\"upper left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Figure 10** shows why you should consider additional boundary conditions for each component of the motion added to the particle.\n" - ] - } - ], - "metadata": { - "celltoolbar": "Metagegevens bewerken", - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.6" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/documentation_unstuck_Agrid.ipynb b/docs/user_guide/examples_v3/documentation_unstuck_Agrid.ipynb deleted file mode 100644 index 5fa518bf5d..0000000000 --- a/docs/user_guide/examples_v3/documentation_unstuck_Agrid.ipynb +++ /dev/null @@ -1,1618 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Preventing stuck particles\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In [another notebook](https://docs.oceanparcels.org/en/latest/examples/documentation_stuck_particles.html), we have shown how particles may end up getting stuck on land, especially in A gridded velocity fields. Here we show how you can work around this problem and how large the effects of the solutions on the trajectories are.\n", - "\n", - "Common solutions are:\n", - "\n", - "1. [Delete the particles](#1.-Particle-deletion)\n", - "2. [Displace the particles when they are within a certain distance of the coast.](#2.-Displacement)\n", - "3. [Implement free-slip or partial-slip boundary conditions](#3.-Slip-boundary-conditions)\n", - "\n", - "In the first two of these solutions, kernels are used to modify the trajectories near the coast. The kernels all consist of two parts:\n", - "\n", - "1. Flag particles whose trajectory should be modified\n", - "2. Modify the trajectory accordingly\n", - "\n", - "In the third solution, the interpolation method is changed; this has to be done when creating the `FieldSet`.\n", - "\n", - "This notebook is mainly focused on comparing the different modifications to the trajectory. The flagging of particles is also very relevant however and further discussion on this is encouraged. Some options shown here are:\n", - "\n", - "1. Flag particles within a specific distance to the shore\n", - "2. Flag particles in any gridcell that has a shore edge\n", - "\n", - "As argued in the [previous notebook](https://docs.oceanparcels.org/en/latest/examples/documentation_stuck_particles.html), it is important to accurately plot the grid discretization, in order to understand the motion of particles near the boundary. The velocity fields can best be depicted using points or arrows that define the velocity at a single position. Four of these nodes then form gridcells that can be shown using tiles, for example with `matplotlib.pyplot.pcolormesh`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from copy import copy\n", - "from datetime import timedelta\n", - "\n", - "import cmocean\n", - "import matplotlib.gridspec as gridspec\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import numpy.ma as ma\n", - "import xarray as xr\n", - "from matplotlib.colors import ListedColormap\n", - "from matplotlib.lines import Line2D\n", - "from netCDF4 import Dataset\n", - "from scipy import interpolate\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. Particle deletion\n", - "\n", - "The simplest way to avoid trajectories that interact with the coastline is to remove them entirely. To do this, all `Particle` objects have a delete function that can be invoked in a kernel using `particle.delete()`\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2. Displacement\n", - "\n", - "A simple concept to avoid particles moving onto shore is displacing them towards the ocean as they get close to shore. This is for example done in [Kaandorp _et al._ (2020)](https://pubs.acs.org/doi/10.1021/acs.est.0c01984) and [Delandmeter and van Sebille (2018)](https://gmd.copernicus.org/articles/12/3571/2019/). To do so, a particle must be 'aware' of where the shore is and displaced accordingly. In Parcels, we can do this by adding a 'displacement' `Field` to the `Fieldset`, which contains vectors pointing away from shore.\n", - "\n", - "**Step 1:** Import a velocity field - the A gridded SMOC product\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "file_path = \"SMOC_20190704_R20190705.nc\"\n", - "model = xr.open_dataset(file_path)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# define meshgrid coordinates to plot velocity field with matplotlib pcolormesh\n", - "latmin = 1595\n", - "latmax = 1612\n", - "lonmin = 2235\n", - "lonmax = 2260\n", - "\n", - "# Velocity nodes\n", - "lon_vals, lat_vals = np.meshgrid(model[\"longitude\"], model[\"latitude\"])\n", - "lons_plot = lon_vals[latmin:latmax, lonmin:lonmax]\n", - "lats_plot = lat_vals[latmin:latmax, lonmin:lonmax]\n", - "\n", - "dlon = 1 / 12\n", - "dlat = 1 / 12\n", - "\n", - "# Centers of the gridcells formed by 4 nodes = velocity nodes + 0.5 dx\n", - "x = model[\"longitude\"][:-1] + np.diff(model[\"longitude\"]) / 2\n", - "y = model[\"latitude\"][:-1] + np.diff(model[\"latitude\"]) / 2\n", - "lon_centers, lat_centers = np.meshgrid(x, y)\n", - "\n", - "color_land = copy(plt.get_cmap(\"Reds\"))(0)\n", - "color_ocean = copy(plt.get_cmap(\"Reds\"))(128)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 2:** Make a landmask where `land = 1` and `ocean = 0`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def make_landmask(fielddata):\n", - " \"\"\"Returns landmask where land = 1 and ocean = 0\n", - " fielddata is a netcdf file.\n", - " \"\"\"\n", - " datafile = Dataset(fielddata)\n", - "\n", - " landmask = datafile.variables[\"uo\"][0, 0]\n", - " landmask = np.ma.masked_invalid(landmask)\n", - " landmask = landmask.mask.astype(\"int\")\n", - "\n", - " return landmask" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "landmask = make_landmask(file_path)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Interpolate the landmask to the cell centers\n", - "# only cells with 4 neighboring land points will be land\n", - "fl = interpolate.RectBivariateSpline(\n", - " model[\"latitude\"], model[\"longitude\"], landmask, kx=1, ky=1\n", - ")\n", - "\n", - "l_centers = fl(lat_centers[:, 0], lon_centers[0, :])\n", - "\n", - "# land when interpolated value == 1\n", - "lmask = np.ma.masked_values(l_centers, 1)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(12, 5))\n", - "fig.suptitle(\"Figure 1. Landmask\", fontsize=18, y=1.01)\n", - "gs = gridspec.GridSpec(ncols=2, nrows=1, figure=fig)\n", - "\n", - "ax0 = fig.add_subplot(gs[0, 0])\n", - "ax0.set_title(\"A) lazy use of pcolormesh\", fontsize=11)\n", - "ax0.set_ylabel(\"Latitude [degrees]\")\n", - "ax0.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - "land0 = ax0.pcolormesh(\n", - " lons_plot,\n", - " lats_plot,\n", - " landmask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - " shading=\"auto\",\n", - ")\n", - "ax0.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=20,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "\n", - "custom_lines = [\n", - " Line2D(\n", - " [0], [0], c=color_ocean, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - "]\n", - "ax0.legend(\n", - " custom_lines,\n", - " [\"ocean point\", \"land point\"],\n", - " bbox_to_anchor=(0.01, 0.93),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ")\n", - "\n", - "ax1 = fig.add_subplot(gs[0, 1])\n", - "ax1.set_title(\"B) correct A grid representation in Parcels\", fontsize=11)\n", - "ax1.set_ylabel(\"Latitude [degrees]\")\n", - "ax1.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - "land1 = ax1.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "ax1.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=20,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "\n", - "ax1.legend(\n", - " custom_lines,\n", - " [\"ocean point\", \"land point\"],\n", - " bbox_to_anchor=(0.01, 0.93),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Figure 1 shows why it is important to be precise when visualizing the model land and ocean. Parcels trajectories should not cross the land boundary between two land nodes as seen in 1B.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 3:** Detect the coast\n", - "\n", - "We can detect the edges between land and ocean nodes by computing the Laplacian with the 4 nearest neighbors `[i+1,j]`, `[i-1,j]`, `[i,j+1]` and `[i,j-1]`:\n", - "\n", - "$$\\nabla^2 \\text{landmask} = \\partial_{xx} \\text{landmask} + \\partial_{yy} \\text{landmask},$$\n", - "\n", - "and filtering the positive and negative values. This gives us the location of _coast_ nodes (ocean nodes next to land) and _shore_ nodes (land nodes next to the ocean).\n", - "\n", - "Additionally, we can find the nodes that border the coast/shore diagonally by considering the 8 nearest neighbors, including `[i+1,j+1]`, `[i-1,j+1]`, `[i-1,j+1]` and `[i-1,j-1]`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def get_coastal_nodes(landmask):\n", - " \"\"\"Function that detects the coastal nodes, i.e. the ocean nodes directly\n", - " next to land. Computes the Laplacian of landmask.\n", - "\n", - " - landmask: the land mask built using `make_landmask`, where land cell = 1\n", - " and ocean cell = 0.\n", - "\n", - " Output: 2D array array containing the coastal nodes, the coastal nodes are\n", - " equal to one, and the rest is zero.\n", - " \"\"\"\n", - " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", - " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", - " mask_lap -= 4 * landmask\n", - " coastal = np.ma.masked_array(landmask, mask_lap > 0)\n", - " coastal = coastal.mask.astype(\"int\")\n", - "\n", - " return coastal\n", - "\n", - "\n", - "def get_shore_nodes(landmask):\n", - " \"\"\"Function that detects the shore nodes, i.e. the land nodes directly\n", - " next to the ocean. Computes the Laplacian of landmask.\n", - "\n", - " - landmask: the land mask built using `make_landmask`, where land cell = 1\n", - " and ocean cell = 0.\n", - "\n", - " Output: 2D array array containing the shore nodes, the shore nodes are\n", - " equal to one, and the rest is zero.\n", - " \"\"\"\n", - " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", - " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", - " mask_lap -= 4 * landmask\n", - " shore = np.ma.masked_array(landmask, mask_lap < 0)\n", - " shore = shore.mask.astype(\"int\")\n", - "\n", - " return shore" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def get_coastal_nodes_diagonal(landmask):\n", - " \"\"\"Function that detects the coastal nodes, i.e. the ocean nodes where\n", - " one of the 8 nearest nodes is land. Computes the Laplacian of landmask\n", - " and the Laplacian of the 45 degree rotated landmask.\n", - "\n", - " - landmask: the land mask built using `make_landmask`, where land cell = 1\n", - " and ocean cell = 0.\n", - "\n", - " Output: 2D array array containing the coastal nodes, the coastal nodes are\n", - " equal to one, and the rest is zero.\n", - " \"\"\"\n", - " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", - " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", - " mask_lap += np.roll(landmask, (-1, 1), axis=(0, 1)) + np.roll(\n", - " landmask, (1, 1), axis=(0, 1)\n", - " )\n", - " mask_lap += np.roll(landmask, (-1, -1), axis=(0, 1)) + np.roll(\n", - " landmask, (1, -1), axis=(0, 1)\n", - " )\n", - " mask_lap -= 8 * landmask\n", - " coastal = np.ma.masked_array(landmask, mask_lap > 0)\n", - " coastal = coastal.mask.astype(\"int\")\n", - "\n", - " return coastal\n", - "\n", - "\n", - "def get_shore_nodes_diagonal(landmask):\n", - " \"\"\"Function that detects the shore nodes, i.e. the land nodes where\n", - " one of the 8 nearest nodes is ocean. Computes the Laplacian of landmask\n", - " and the Laplacian of the 45 degree rotated landmask.\n", - "\n", - " - landmask: the land mask built using `make_landmask`, where land cell = 1\n", - " and ocean cell = 0.\n", - "\n", - " Output: 2D array array containing the shore nodes, the shore nodes are\n", - " equal to one, and the rest is zero.\n", - " \"\"\"\n", - " mask_lap = np.roll(landmask, -1, axis=0) + np.roll(landmask, 1, axis=0)\n", - " mask_lap += np.roll(landmask, -1, axis=1) + np.roll(landmask, 1, axis=1)\n", - " mask_lap += np.roll(landmask, (-1, 1), axis=(0, 1)) + np.roll(\n", - " landmask, (1, 1), axis=(0, 1)\n", - " )\n", - " mask_lap += np.roll(landmask, (-1, -1), axis=(0, 1)) + np.roll(\n", - " landmask, (1, -1), axis=(0, 1)\n", - " )\n", - " mask_lap -= 8 * landmask\n", - " shore = np.ma.masked_array(landmask, mask_lap < 0)\n", - " shore = shore.mask.astype(\"int\")\n", - "\n", - " return shore" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "coastal = get_coastal_nodes_diagonal(landmask)\n", - "shore = get_shore_nodes_diagonal(landmask)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(10, 4), constrained_layout=True)\n", - "fig.suptitle(\"Figure 2. Coast and Shore\", fontsize=18, y=1.04)\n", - "gs = gridspec.GridSpec(ncols=2, nrows=1, figure=fig)\n", - "\n", - "\n", - "ax0 = fig.add_subplot(gs[0, 0])\n", - "land0 = ax0.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "coa = ax0.scatter(\n", - " lons_plot, lats_plot, c=coastal[latmin:latmax, lonmin:lonmax], cmap=\"Reds_r\", s=50\n", - ")\n", - "ax0.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=20,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - ")\n", - "\n", - "ax0.set_title(\"Coast\")\n", - "ax0.set_ylabel(\"Latitude [degrees]\")\n", - "ax0.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - "custom_lines = [\n", - " Line2D([0], [0], c=color_ocean, marker=\"o\", markersize=5, lw=0),\n", - " Line2D(\n", - " [0],\n", - " [0],\n", - " c=color_ocean,\n", - " marker=\"o\",\n", - " markersize=7,\n", - " markeredgecolor=\"w\",\n", - " markeredgewidth=2,\n", - " lw=0,\n", - " ),\n", - " Line2D(\n", - " [0],\n", - " [0],\n", - " c=color_land,\n", - " marker=\"o\",\n", - " markersize=7,\n", - " markeredgecolor=\"firebrick\",\n", - " lw=0,\n", - " ),\n", - "]\n", - "ax0.legend(\n", - " custom_lines,\n", - " [\"ocean node\", \"coast node\", \"land node\"],\n", - " bbox_to_anchor=(0.01, 0.9),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - " facecolor=\"silver\",\n", - ")\n", - "\n", - "\n", - "ax1 = fig.add_subplot(gs[0, 1])\n", - "land1 = ax1.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "sho = ax1.scatter(\n", - " lons_plot, lats_plot, c=shore[latmin:latmax, lonmin:lonmax], cmap=\"Reds_r\", s=50\n", - ")\n", - "ax1.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=20,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - ")\n", - "\n", - "ax1.set_title(\"Shore\")\n", - "ax1.set_ylabel(\"Latitude [degrees]\")\n", - "ax1.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - "custom_lines = [\n", - " Line2D([0], [0], c=color_ocean, marker=\"o\", markersize=5, lw=0),\n", - " Line2D(\n", - " [0],\n", - " [0],\n", - " c=color_land,\n", - " marker=\"o\",\n", - " markersize=7,\n", - " markeredgecolor=\"w\",\n", - " markeredgewidth=2,\n", - " lw=0,\n", - " ),\n", - " Line2D(\n", - " [0],\n", - " [0],\n", - " c=color_land,\n", - " marker=\"o\",\n", - " markersize=7,\n", - " markeredgecolor=\"firebrick\",\n", - " lw=0,\n", - " ),\n", - "]\n", - "ax1.legend(\n", - " custom_lines,\n", - " [\"ocean node\", \"shore node\", \"land node\"],\n", - " bbox_to_anchor=(0.01, 0.9),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - " facecolor=\"silver\",\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 4:** Assigning coastal velocities\n", - "\n", - "For the displacement kernel we define a velocity field that pushes the particles back to the ocean. This velocity is a vector normal to the shore.\n", - "\n", - "For the shore nodes directly next to the ocean, we can take the simple derivative of `landmask` and project the result to the `shore` array, this will capture the orientation of the velocity vectors.\n", - "\n", - "For the shore nodes that only have a diagonal component, we need to take into account the diagonal nodes also and project the vectors only onto the inside corners that border the ocean diagonally.\n", - "\n", - "Then to make the vectors unitary, we normalize them by their magnitude.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def create_displacement_field(landmask, double_cell=False):\n", - " \"\"\"Function that creates a displacement field 1 m/s away from the shore.\n", - "\n", - " - landmask: the land mask dUilt using `make_landmask`.\n", - " - double_cell: Boolean for determining if you want a double cell.\n", - " Default set to False.\n", - "\n", - " Output: two 2D arrays, one for each camponent of the velocity.\n", - " \"\"\"\n", - " shore = get_shore_nodes(landmask)\n", - "\n", - " # nodes bordering ocean directly and diagonally\n", - " shore_d = get_shore_nodes_diagonal(landmask)\n", - " # corner nodes that only border ocean diagonally\n", - " shore_c = shore_d - shore\n", - "\n", - " # Simple derivative\n", - " Ly = np.roll(landmask, -1, axis=0) - np.roll(landmask, 1, axis=0)\n", - " Lx = np.roll(landmask, -1, axis=1) - np.roll(landmask, 1, axis=1)\n", - "\n", - " Ly_c = np.roll(landmask, -1, axis=0) - np.roll(landmask, 1, axis=0)\n", - " # Include y-component of diagonal neighbors\n", - " Ly_c += np.roll(landmask, (-1, -1), axis=(0, 1)) + np.roll(\n", - " landmask, (-1, 1), axis=(0, 1)\n", - " )\n", - " Ly_c += -np.roll(landmask, (1, -1), axis=(0, 1)) - np.roll(\n", - " landmask, (1, 1), axis=(0, 1)\n", - " )\n", - "\n", - " Lx_c = np.roll(landmask, -1, axis=1) - np.roll(landmask, 1, axis=1)\n", - " # Include x-component of diagonal neighbors\n", - " Lx_c += np.roll(landmask, (-1, -1), axis=(1, 0)) + np.roll(\n", - " landmask, (-1, 1), axis=(1, 0)\n", - " )\n", - " Lx_c += -np.roll(landmask, (1, -1), axis=(1, 0)) - np.roll(\n", - " landmask, (1, 1), axis=(1, 0)\n", - " )\n", - "\n", - " v_x = -Lx * (shore)\n", - " v_y = -Ly * (shore)\n", - "\n", - " v_x_c = -Lx_c * (shore_c)\n", - " v_y_c = -Ly_c * (shore_c)\n", - "\n", - " v_x = v_x + v_x_c\n", - " v_y = v_y + v_y_c\n", - "\n", - " magnitude = np.sqrt(v_y**2 + v_x**2)\n", - " # the coastal nodes between land create a problem. Magnitude there is zero\n", - " # I force it to be 1 to avoid problems when normalizing.\n", - " ny, nx = np.where(magnitude == 0)\n", - " magnitude[ny, nx] = 1\n", - "\n", - " v_x = v_x / magnitude\n", - " v_y = v_y / magnitude\n", - "\n", - " return v_x, v_y" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "v_x, v_y = create_displacement_field(landmask)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(7, 6), constrained_layout=True)\n", - "fig.suptitle(\"Figure 3. Displacement field\", fontsize=18, y=1.04)\n", - "gs = gridspec.GridSpec(ncols=1, nrows=1, figure=fig)\n", - "\n", - "ax0 = fig.add_subplot(gs[0, 0])\n", - "land = ax0.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "ax0.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=30,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "quiv = ax0.quiver(\n", - " lons_plot,\n", - " lats_plot,\n", - " v_x[latmin:latmax, lonmin:lonmax],\n", - " v_y[latmin:latmax, lonmin:lonmax],\n", - " color=\"orange\",\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=19,\n", - " width=0.005,\n", - ")\n", - "\n", - "ax0.set_ylabel(\"Latitude [degrees]\")\n", - "ax0.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - "custom_lines = [\n", - " Line2D(\n", - " [0], [0], c=color_ocean, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - "]\n", - "ax0.legend(\n", - " custom_lines,\n", - " [\"ocean point\", \"land point\"],\n", - " bbox_to_anchor=(0.01, 0.93),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 5:** Calculate the distance to the shore\n", - "\n", - "In this tutorial, we will only displace particles that are within some distance (smaller than the grid size) to the shore.\n", - "\n", - "For this we map the distance of the coastal nodes to the shore: Coastal nodes directly neighboring the shore are $1dx$ away. Diagonal neighbors are $\\sqrt{2}dx$ away. The particles can then sample this field and will only be displaced when closer than a threshold value. This gives a crude estimate of the distance.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def distance_to_shore(landmask, dx=1):\n", - " \"\"\"Function that computes the distance to the shore. It is based in the\n", - " the `get_coastal_nodes` algorithm.\n", - "\n", - " - landmask: the land mask dUilt using `make_landmask` function.\n", - " - dx: the grid cell dimension. This is a crude approxsimation of the real\n", - " distance (be careful).\n", - "\n", - " Output: 2D array containing the distances from shore.\n", - " \"\"\"\n", - " ci = get_coastal_nodes(landmask) # direct neighbors\n", - " dist = ci * dx # 1 dx away\n", - "\n", - " ci_d = get_coastal_nodes_diagonal(landmask) # diagonal neighbors\n", - " dist_d = (ci_d - ci) * np.sqrt(2 * dx**2) # sqrt(2) dx away\n", - "\n", - " return dist + dist_d" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d_2_s = distance_to_shore(landmask)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(6, 5), constrained_layout=True)\n", - "\n", - "ax0 = fig.add_subplot()\n", - "ax0.set_title(\"Figure 4. Distance to shore\", fontsize=18)\n", - "ax0.set_ylabel(\"Latitude [degrees]\")\n", - "ax0.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - "land = ax0.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "d2s = ax0.scatter(lons_plot, lats_plot, c=d_2_s[latmin:latmax, lonmin:lonmax])\n", - "\n", - "plt.colorbar(d2s, ax=ax0, label=\"Distance [gridcells]\");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 6:** Particle and Kernels\n", - "\n", - "The distance to shore, used to flag whether a particle must be displaced, is stored in a particle `Variable` `d2s`. To visualize the displacement, the zonal and meridional displacements are stored in the variables `dU` and `dV`.\n", - "\n", - "To write the displacement vector to the output before displacing the particle, the `set_displacement` kernel is invoked after the advection kernel. Then only in the next timestep are particles displaced by `displace`, before resuming the advection.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "DisplacementParticle = parcels.Particle.add_variables(\n", - " [\n", - " parcels.Variable(\"dU\"),\n", - " parcels.Variable(\"dV\"),\n", - " parcels.Variable(\"d2s\", initial=1e3),\n", - " ]\n", - ")\n", - "\n", - "\n", - "def set_displacement(particle, fieldset, time):\n", - " particle.d2s = fieldset.distance2shore[\n", - " time, particle.depth, particle.lat, particle.lon\n", - " ]\n", - " if particle.d2s < 0.5:\n", - " dispUab = fieldset.dispU[time, particle.depth, particle.lat, particle.lon]\n", - " dispVab = fieldset.dispV[time, particle.depth, particle.lat, particle.lon]\n", - " particle.dU = dispUab\n", - " particle.dV = dispVab\n", - " else:\n", - " particle.dU = 0.0\n", - " particle.dV = 0.0\n", - "\n", - "\n", - "def displace(particle, fieldset, time):\n", - " if particle.d2s < 0.5:\n", - " particle_dlon += particle.dU * particle.dt\n", - " particle_dlat += particle.dV * particle.dt" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 7:** Simulation\n", - "\n", - "Let us first do a simulation with the default AdvectionRK4 kernel for comparison later\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "SMOCfile = \"SMOC_201907*.nc\"\n", - "filenames = {\"U\": SMOCfile, \"V\": SMOCfile}\n", - "\n", - "variables = {\"U\": \"uo\", \"V\": \"vo\"}\n", - "\n", - "dims = {\"lon\": \"longitude\", \"lat\": \"latitude\", \"depth\": \"depth\", \"time\": \"time\"}\n", - "dimensions = {\"U\": dims, \"V\": dims}\n", - "\n", - "indices = {\n", - " \"lon\": range(lonmin, lonmax), # TODO v4: Remove `indices` argument from this cell\n", - " \"lat\": range(latmin, latmax),\n", - "} # to load only a small part of the domain\n", - "\n", - "fieldset = parcels.FieldSet.from_netcdf(\n", - " filenames, variables, dimensions, indices=indices\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And we use the following set of 9 particles\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "npart = 9 # number of particles to be released\n", - "lon = np.linspace(7, 7.2, int(np.sqrt(npart)), dtype=np.float32)\n", - "lat = np.linspace(53.45, 53.65, int(np.sqrt(npart)), dtype=np.float32)\n", - "lons, lats = np.meshgrid(lon, lat)\n", - "time = np.zeros(lons.size)\n", - "\n", - "runtime = timedelta(hours=100)\n", - "dt = timedelta(minutes=10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=parcels.Particle, lon=lons, lat=lats, time=time\n", - ")\n", - "\n", - "kernels = parcels.kernels.AdvectionRK4\n", - "\n", - "output_file = pset.ParticleFile(name=\"SMOC.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "pset.execute(kernels, runtime=runtime, dt=dt, output_file=output_file)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now let's add the Fields we created above to the FieldSet and do a simulation to test the displacement of the particles as they approach the shore.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldset = parcels.FieldSet.from_netcdf(\n", - " filenames,\n", - " variables,\n", - " dimensions,\n", - " indices=indices, # TODO v4: Remove `indices` argument from this cell\n", - ")\n", - "u_displacement = v_x\n", - "v_displacement = v_y\n", - "fieldset.add_field(\n", - " parcels.Field(\n", - " \"dispU\",\n", - " data=u_displacement[latmin:latmax, lonmin:lonmax],\n", - " lon=fieldset.U.grid.lon,\n", - " lat=fieldset.U.grid.lat,\n", - " mesh=\"spherical\",\n", - " )\n", - ")\n", - "fieldset.add_field(\n", - " parcels.Field(\n", - " \"dispV\",\n", - " data=v_displacement[latmin:latmax, lonmin:lonmax],\n", - " lon=fieldset.U.grid.lon,\n", - " lat=fieldset.U.grid.lat,\n", - " mesh=\"spherical\",\n", - " )\n", - ")\n", - "fieldset.dispU.units = parcels.GeographicPolar()\n", - "fieldset.dispV.units = parcels.Geographic()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldset.add_field(\n", - " parcels.Field(\n", - " \"landmask\",\n", - " landmask[latmin:latmax, lonmin:lonmax],\n", - " lon=fieldset.U.grid.lon,\n", - " lat=fieldset.U.grid.lat,\n", - " mesh=\"spherical\",\n", - " )\n", - ")\n", - "fieldset.add_field(\n", - " parcels.Field(\n", - " \"distance2shore\",\n", - " d_2_s[latmin:latmax, lonmin:lonmax],\n", - " lon=fieldset.U.grid.lon,\n", - " lat=fieldset.U.grid.lat,\n", - " mesh=\"spherical\",\n", - " )\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=DisplacementParticle, lon=lons, lat=lats, time=time\n", - ")\n", - "\n", - "kernels = [displace, parcels.kernels.AdvectionRK4, set_displacement]\n", - "\n", - "output_file = pset.ParticleFile(name=\"SMOC-disp.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "pset.execute(kernels, runtime=runtime, dt=dt, output_file=output_file)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Step 10:** Output\n", - "\n", - "To visualize the effect of the displacement, the particle trajectory output can be compared to the simulation without the displacement kernel.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds_SMOC = xr.open_zarr(\"SMOC.zarr\")\n", - "ds_SMOC_disp = xr.open_zarr(\"SMOC-disp.zarr\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(16, 4), facecolor=\"silver\", constrained_layout=True)\n", - "fig.suptitle(\"Figure 5. Trajectory difference\", fontsize=18, y=1.06)\n", - "gs = gridspec.GridSpec(ncols=4, nrows=1, width_ratios=[1, 1, 1, 0.3], figure=fig)\n", - "\n", - "ax0 = fig.add_subplot(gs[0, 0])\n", - "ax0.set_ylabel(\"Latitude [degrees]\")\n", - "ax0.set_xlabel(\"Longitude [degrees]\")\n", - "ax0.set_title(\"A) No displacement\", fontsize=14, fontweight=\"bold\")\n", - "ax0.set_xlim(6.9, 7.6)\n", - "ax0.set_ylim(53.4, 53.8)\n", - "\n", - "land = ax0.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "ax0.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=50,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "ax0.plot(ds_SMOC[\"lon\"].T, ds_SMOC[\"lat\"].T, linewidth=3, zorder=1)\n", - "ax0.scatter(ds_SMOC[\"lon\"], ds_SMOC[\"lat\"], color=\"limegreen\", zorder=2)\n", - "\n", - "n_p0 = 0\n", - "ax1 = fig.add_subplot(gs[0, 1])\n", - "ax1.set_ylabel(\"Latitude [degrees]\")\n", - "ax1.set_xlabel(\"Longitude [degrees]\")\n", - "ax1.set_title(\"B) Displacement trajectory \" + str(n_p0), fontsize=14, fontweight=\"bold\")\n", - "ax1.set_xlim(6.9, 7.3)\n", - "ax1.set_ylim(53.4, 53.55)\n", - "\n", - "land = ax1.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "ax1.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=50,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "quiv = ax1.quiver(\n", - " lons_plot,\n", - " lats_plot,\n", - " v_x[latmin:latmax, lonmin:lonmax],\n", - " v_y[latmin:latmax, lonmin:lonmax],\n", - " color=\"orange\",\n", - " scale=19,\n", - " width=0.005,\n", - ")\n", - "ax1.plot(\n", - " ds_SMOC_disp[\"lon\"][n_p0].T, ds_SMOC_disp[\"lat\"][n_p0].T, linewidth=3, zorder=1\n", - ")\n", - "ax1.scatter(ds_SMOC[\"lon\"][n_p0], ds_SMOC[\"lat\"][n_p0], color=\"limegreen\", zorder=2)\n", - "ax1.scatter(ds_SMOC_disp[\"lon\"][n_p0], ds_SMOC_disp[\"lat\"][n_p0], color=\"C00\", zorder=2)\n", - "ax1.quiver(\n", - " ds_SMOC_disp[\"lon\"][n_p0],\n", - " ds_SMOC_disp[\"lat\"][n_p0],\n", - " ds_SMOC_disp[\"dU\"][n_p0],\n", - " ds_SMOC_disp[\"dV\"][n_p0],\n", - " color=\"w\",\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=2e-4,\n", - " zorder=3,\n", - ")\n", - "\n", - "n_p1 = 4\n", - "ax2 = fig.add_subplot(gs[0, 2])\n", - "ax2.set_ylabel(\"Latitude [degrees]\")\n", - "ax2.set_xlabel(\"Longitude [degrees]\")\n", - "ax2.set_title(\"C) Displacement trajectory \" + str(n_p1), fontsize=14, fontweight=\"bold\")\n", - "ax2.set_xlim(7.0, 7.6)\n", - "ax2.set_ylim(53.4, 53.8)\n", - "\n", - "land = ax2.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "ax2.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=50,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - ")\n", - "q1 = ax2.quiver(\n", - " lons_plot,\n", - " lats_plot,\n", - " v_x[latmin:latmax, lonmin:lonmax],\n", - " v_y[latmin:latmax, lonmin:lonmax],\n", - " color=\"orange\",\n", - " scale=19,\n", - " width=0.005,\n", - ")\n", - "ax2.plot(\n", - " ds_SMOC_disp[\"lon\"][n_p1].T, ds_SMOC_disp[\"lat\"][n_p1].T, linewidth=3, zorder=1\n", - ")\n", - "ax2.scatter(ds_SMOC[\"lon\"][n_p1], ds_SMOC[\"lat\"][n_p1], color=\"limegreen\", zorder=2)\n", - "ax2.scatter(ds_SMOC_disp[\"lon\"][n_p1], ds_SMOC_disp[\"lat\"][n_p1], zorder=2)\n", - "q2 = ax2.quiver(\n", - " ds_SMOC_disp[\"lon\"][n_p1],\n", - " ds_SMOC_disp[\"lat\"][n_p1],\n", - " ds_SMOC_disp[\"dU\"][n_p1],\n", - " ds_SMOC_disp[\"dV\"][n_p1],\n", - " color=\"w\",\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=2e-4,\n", - " zorder=3,\n", - ")\n", - "\n", - "ax3 = fig.add_subplot(gs[0, 3])\n", - "ax3.axis(\"off\")\n", - "custom_lines = [\n", - " Line2D([0], [0], c=\"tab:blue\", marker=\"o\", markersize=10),\n", - " Line2D([0], [0], c=\"limegreen\", marker=\"o\", markersize=10),\n", - " Line2D(\n", - " [0], [0], c=color_ocean, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - "]\n", - "ax3.legend(\n", - " custom_lines,\n", - " [\"with displacement\", \"without displacement\", \"ocean point\", \"land point\"],\n", - " bbox_to_anchor=(0.0, 0.6),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ")\n", - "\n", - "ax2.quiverkey(q1, 1.3, 0.9, 2, \"displacement field\", coordinates=\"axes\")\n", - "ax2.quiverkey(q2, 1.3, 0.8, 1e-5, \"particle displacement\", coordinates=\"axes\")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Conclusion\n", - "\n", - "Figure 5 shows how particles are prevented from approaching the coast in a 5 day simulation. Note that to show each computation, the integration timestep (`dt`) is equal to the output timestep (`outputdt`): 1 hour. This is relatively large, and causes the displacement to be on the order of 4 km and be relatively infrequent. It is advised to use smaller `dt` in real simulations.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d2s_cmap = copy(plt.get_cmap(\"cmo.deep_r\"))\n", - "d2s_cmap.set_over(\"gold\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(11, 6), constrained_layout=True)\n", - "\n", - "ax0 = fig.add_subplot()\n", - "ax0.set_title(\"Figure 6. Distance to shore\", fontsize=18)\n", - "land = ax0.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - ")\n", - "ax0.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=50,\n", - " cmap=\"Reds_r\",\n", - " edgecolor=\"k\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - ")\n", - "ax0.plot(ds_SMOC_disp[\"lon\"].T, ds_SMOC_disp[\"lat\"].T, linewidth=3, zorder=1)\n", - "d2s = ax0.scatter(\n", - " ds_SMOC_disp[\"lon\"],\n", - " ds_SMOC_disp[\"lat\"],\n", - " c=ds_SMOC_disp[\"d2s\"],\n", - " cmap=d2s_cmap,\n", - " s=20,\n", - " vmax=0.5,\n", - " zorder=2,\n", - ")\n", - "q2 = ax0.quiver(\n", - " ds_SMOC_disp[\"lon\"],\n", - " ds_SMOC_disp[\"lat\"],\n", - " ds_SMOC_disp[\"dU\"],\n", - " ds_SMOC_disp[\"dV\"],\n", - " color=\"k\",\n", - " angles=\"xy\",\n", - " scale_units=\"xy\",\n", - " scale=2.3e-4,\n", - " width=0.003,\n", - " zorder=3,\n", - ")\n", - "\n", - "ax0.set_xlim(6.9, 8)\n", - "ax0.set_ylim(53.4, 53.8)\n", - "ax0.set_ylabel(\"Latitude [degrees]\")\n", - "ax0.set_xlabel(\"Longitude [degrees]\")\n", - "plt.colorbar(d2s, ax=ax0, label=\"Distance [gridcells]\", extend=\"max\")\n", - "\n", - "color_land = copy(plt.get_cmap(\"Reds\"))(0)\n", - "color_ocean = copy(plt.get_cmap(\"Reds\"))(128)\n", - "\n", - "custom_lines = [\n", - " Line2D(\n", - " [0], [0], c=color_ocean, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - "]\n", - "ax0.legend(\n", - " custom_lines,\n", - " [\"ocean point\", \"land point\"],\n", - " bbox_to_anchor=(0.01, 0.95),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - ");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3. Slip boundary conditions\n", - "\n", - "The reason trajectories do not neatly follow the coast in A grid velocity fields is that the lack of staggering causes both velocity components to go to zero in the same way towards the cell edge. This no-slip condition can be turned into a free-slip or partial-slip condition by separately considering the cross-shore and along-shore velocity components as in [a staggered C-grid](https://docs.oceanparcels.org/en/latest/examples/documentation_stuck_particles.html#2.-C-grids). Each interpolation of the velocity field must then be corrected with a factor depending on the direction of the boundary.\n", - "\n", - "
\n", - "\n", - "__Note__ that while the code below has been developed for A-grids, we have experience that it can also work well with B-grids. So if you encounter stuck particles in a B-grid model then it might be worth to use the `interp_method=partialslip` or `interp_method=freeslip` solutions described below.\n", - "
\n", - "\n", - "These boundary conditions have been implemented in Parcels as `interp_method=partialslip` and `interp_method=freeslip`, which we will show in the plot below\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cells_x = np.array([[0, 0], [1, 1], [2, 2]])\n", - "cells_y = np.array([[0, 1], [0, 1], [0, 1]])\n", - "U0 = 1\n", - "V0 = 1\n", - "U = np.array([U0, U0, 0, 0, 0, 0])\n", - "V = np.array([V0, V0, 0, 0, 0, 0])\n", - "xsi = np.linspace(0.001, 0.999)\n", - "\n", - "u_interp = U0 * (1 - xsi)\n", - "v_interp = V0 * (1 - xsi)\n", - "\n", - "u_freeslip = u_interp\n", - "v_freeslip = v_interp / (1 - xsi)\n", - "\n", - "u_partslip = u_interp\n", - "v_partslip = v_interp * (1 - 0.5 * xsi) / (1 - xsi)\n", - "\n", - "\n", - "fig = plt.figure(figsize=(15, 4), constrained_layout=True)\n", - "fig.suptitle(\"Figure 7. Boundary conditions\", fontsize=18, y=1.06)\n", - "gs = gridspec.GridSpec(ncols=3, nrows=1, figure=fig)\n", - "\n", - "ax0 = fig.add_subplot(gs[0, 0])\n", - "\n", - "ax0.pcolormesh(cells_x, cells_y, np.array([[0], [1]]), cmap=\"Greys\", edgecolor=\"k\")\n", - "ax0.scatter(cells_x, cells_y, c=\"w\", edgecolor=\"k\")\n", - "ax0.quiver(cells_x, cells_y, U, V, scale=15)\n", - "\n", - "ax0.plot(xsi, u_interp, linewidth=5, label=\"u_interpolation\")\n", - "ax0.plot(xsi, v_interp, linestyle=\"dashed\", linewidth=5, label=\"v_interpolation\")\n", - "ax0.set_xlim(-0.3, 2.3)\n", - "ax0.set_ylim(-0.5, 1.5)\n", - "ax0.set_ylabel(\"u - v [-]\", fontsize=14)\n", - "ax0.set_xlabel(r\"$\\xi$\", fontsize=14)\n", - "ax0.set_title(\"A) Bilinear interpolation\")\n", - "ax0.legend(loc=\"lower right\")\n", - "\n", - "\n", - "ax1 = fig.add_subplot(gs[0, 1])\n", - "\n", - "ax1.pcolormesh(cells_x, cells_y, np.array([[0], [1]]), cmap=\"Greys\", edgecolor=\"k\")\n", - "ax1.scatter(cells_x, cells_y, c=\"w\", edgecolor=\"k\")\n", - "ax1.quiver(cells_x, cells_y, U, V, scale=15)\n", - "\n", - "ax1.plot(xsi, u_freeslip, linewidth=5, label=\"u_freeslip\")\n", - "ax1.plot(xsi, v_freeslip, linestyle=\"dashed\", linewidth=5, label=\"v_freeslip\")\n", - "ax1.set_xlim(-0.3, 2.3)\n", - "ax1.set_ylim(-0.5, 1.5)\n", - "ax1.set_xlabel(r\"$\\xi$\", fontsize=14)\n", - "ax1.text(0.0, 1.3, r\"$v_{freeslip} = v_{interpolation}*\\frac{1}{1-\\xi}$\", fontsize=18)\n", - "ax1.set_title(\"B) Free slip condition\")\n", - "ax1.legend(loc=\"lower right\")\n", - "\n", - "ax2 = fig.add_subplot(gs[0, 2])\n", - "\n", - "ax2.pcolormesh(cells_x, cells_y, np.array([[0], [1]]), cmap=\"Greys\", edgecolor=\"k\")\n", - "ax2.scatter(cells_x, cells_y, c=\"w\", edgecolor=\"k\")\n", - "ax2.quiver(cells_x, cells_y, U, V, scale=15)\n", - "\n", - "ax2.plot(xsi, u_partslip, linewidth=5, label=\"u_partialslip\")\n", - "ax2.plot(xsi, v_partslip, linestyle=\"dashed\", linewidth=5, label=\"v_partialslip\")\n", - "ax2.set_xlim(-0.3, 2.3)\n", - "ax2.set_ylim(-0.5, 1.5)\n", - "ax2.set_xlabel(r\"$\\xi$\", fontsize=14)\n", - "ax2.text(\n", - " 0.0,\n", - " 1.3,\n", - " r\"$v_{partialslip} = v_{interpolation}*\\frac{1-1/2\\xi}{1-\\xi}$\",\n", - " fontsize=18,\n", - ")\n", - "ax2.set_title(\"C) Partial slip condition\")\n", - "ax2.legend(loc=\"lower right\");" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Consider a grid cell with a solid boundary to the right and vectors $(U0, V0)$ = $(1, 1)$ on the left-hand nodes, as in **figure 7**. Parcels bilinear interpolation will interpolate in the $x$ and $y$ directions. This cell is invariant in the $y$-direction, we will only consider the effect in the direction normal to the boundary. In the x-direction, both u and v will be interpolated along $\\xi$, the normalized $x$-coordinate within the cell. This is plotted with the blue and orange dashed lines in **subfigure 7A**.\n", - "\n", - "A free slip boundary condition is defined with $\\frac{\\delta v}{\\delta \\xi}=0$. This means that the tangential velocity is constant in the direction normal to the boundary. This can be achieved in a kernel after interpolation by dividing by $(1-\\xi)$. The resulting velocity profiles are shown in **subfigure 7B**.\n", - "\n", - "A partial slip boundary condition is defined with a tangential velocity profile that decreases toward the boundary, but not to zero. This can be achieved by multiplying the interpolated velocity by $\\frac{1-1/2\\xi}{1-\\xi}$. This is shown in **subfigure 7C**.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For each direction and boundary condition a different factor must be used (where $\\xi$ and $\\eta$ are the normalized x- and y-coordinates within the cell, respectively):\n", - "\n", - "- Free slip\n", - "\n", - " 1: $f_u = \\frac{1}{\\eta}$\n", - "\n", - " 2: $f_u = \\frac{1}{(1-\\eta)}$\n", - "\n", - " 4: $f_v = \\frac{1}{\\xi}$\n", - "\n", - " 8: $f_v = \\frac{1}{(1-\\xi)}$\n", - "\n", - "- Partial slip\n", - "\n", - " 1: $f_u = \\frac{1/2+1/2\\eta}{\\eta}$\n", - "\n", - " 2: $f_u = \\frac{1-1/2\\eta}{1-\\eta}$\n", - "\n", - " 4: $f_v = \\frac{1/2+1/2\\xi}{\\xi}$\n", - "\n", - " 8: $f_v = \\frac{1-1/2\\xi}{1-\\xi}$\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We now simulate the three different boundary conditions by advecting the 9 particles from above in a time-evolving SMOC dataset from the [Copernicus Marine Data Store](https://data.marine.copernicus.eu/product/GLOBAL_ANALYSISFORECAST_PHY_001_024/services).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "SMOCfiles = \"SMOC_201907*.nc\"\n", - "filenames = {\"U\": SMOCfile, \"V\": SMOCfile}\n", - "\n", - "variables = {\"U\": \"uo\", \"V\": \"vo\"}\n", - "\n", - "dims = {\"lon\": \"longitude\", \"lat\": \"latitude\", \"depth\": \"depth\", \"time\": \"time\"}\n", - "dimensions = {\"U\": dims, \"V\": dims}\n", - "\n", - "indices = {\"lon\": range(lonmin, lonmax), \"lat\": range(latmin, latmax)}" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "First up is the **partialslip interpolation** (note that we have to redefine the `FieldSet` because the `interp_method=partialslip` is set there)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldset = parcels.FieldSet.from_netcdf(\n", - " filenames,\n", - " variables,\n", - " dimensions,\n", - " indices=indices, # TODO v4: Remove `indices` argument from this cell\n", - " interp_method={\n", - " \"U\": \"partialslip\",\n", - " \"V\": \"partialslip\",\n", - " }, # Setting the interpolation for U and V\n", - ")\n", - "\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=parcels.Particle, lon=lons, lat=lats, time=time\n", - ")\n", - "\n", - "kernels = pset.Kernel(parcels.kernels.AdvectionRK4)\n", - "\n", - "output_file = pset.ParticleFile(\n", - " name=\"SMOC_partialslip.zarr\", outputdt=timedelta(hours=1)\n", - ")\n", - "\n", - "pset.execute(kernels, runtime=runtime, dt=dt, output_file=output_file)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And then we also use the **freeslip** interpolation\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldset = parcels.FieldSet.from_netcdf(\n", - " filenames,\n", - " variables,\n", - " dimensions,\n", - " indices=indices, # TODO v4: Remove `indices` argument from this cell\n", - " interp_method={\n", - " \"U\": \"freeslip\",\n", - " \"V\": \"freeslip\",\n", - " }, # Setting the interpolation for U and V\n", - ")\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=parcels.Particle, lon=lons, lat=lats, time=time\n", - ")\n", - "\n", - "kernels = pset.Kernel(parcels.kernels.AdvectionRK4)\n", - "\n", - "output_file = pset.ParticleFile(name=\"SMOC_freeslip.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "pset.execute(kernels, runtime=runtime, dt=dt, output_file=output_file)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we can load and plot the three different `interpolation_methods`\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds_SMOC = xr.open_zarr(\"SMOC.zarr\")\n", - "ds_SMOC_part = xr.open_zarr(\"SMOC_partialslip.zarr\")\n", - "ds_SMOC_free = xr.open_zarr(\"SMOC_freeslip.zarr\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = plt.figure(figsize=(18, 5), constrained_layout=True)\n", - "fig.suptitle(\"Figure 8. Solution comparison\", fontsize=18, y=1.06)\n", - "gs = gridspec.GridSpec(ncols=3, nrows=1, figure=fig)\n", - "\n", - "n_p = [[0, 1, 3, 4, 6, 7, 8], 0, 6]\n", - "\n", - "for i in range(3):\n", - " ax = fig.add_subplot(gs[0, i])\n", - " ax.set_title(chr(i + 65) + \") Trajectory \" + str(n_p[i]), fontsize=18)\n", - " land = ax.pcolormesh(\n", - " lon_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lat_vals[latmin : latmax + 1, lonmin : lonmax + 1],\n", - " lmask.mask[latmin:latmax, lonmin:lonmax],\n", - " cmap=\"Reds_r\",\n", - " )\n", - " ax.scatter(\n", - " lons_plot,\n", - " lats_plot,\n", - " c=landmask[latmin:latmax, lonmin:lonmax],\n", - " s=50,\n", - " cmap=\"Reds_r\",\n", - " vmin=-0.05,\n", - " vmax=0.05,\n", - " edgecolors=\"k\",\n", - " )\n", - "\n", - " ax.scatter(\n", - " ds_SMOC[\"lon\"][n_p[i]],\n", - " ds_SMOC[\"lat\"][n_p[i]],\n", - " s=30,\n", - " color=\"limegreen\",\n", - " zorder=2,\n", - " )\n", - "\n", - " ax.scatter(\n", - " ds_SMOC_disp[\"lon\"][n_p[i]],\n", - " ds_SMOC_disp[\"lat\"][n_p[i]],\n", - " s=25,\n", - " color=\"tab:blue\",\n", - " zorder=2,\n", - " )\n", - "\n", - " ax.scatter(\n", - " ds_SMOC_part[\"lon\"][n_p[i]],\n", - " ds_SMOC_part[\"lat\"][n_p[i]],\n", - " s=20,\n", - " color=\"magenta\",\n", - " zorder=2,\n", - " )\n", - "\n", - " ax.scatter(\n", - " ds_SMOC_free[\"lon\"][n_p[i]],\n", - " ds_SMOC_free[\"lat\"][n_p[i]],\n", - " s=15,\n", - " color=\"gold\",\n", - " zorder=2,\n", - " )\n", - "\n", - " ax.set_xlim(6.9, 7.6)\n", - " ax.set_ylim(53.4, 53.9)\n", - " ax.set_ylabel(\"Latitude [degrees]\")\n", - " ax.set_xlabel(\"Longitude [degrees]\")\n", - "\n", - " color_land = copy(plt.get_cmap(\"Reds\"))(0)\n", - " color_ocean = copy(plt.get_cmap(\"Reds\"))(128)\n", - "\n", - " custom_lines = [\n", - " Line2D([0], [0], c=\"limegreen\", marker=\"o\", markersize=10, lw=0),\n", - " Line2D([0], [0], c=\"tab:blue\", marker=\"o\", markersize=10, lw=0),\n", - " Line2D([0], [0], c=\"magenta\", marker=\"o\", markersize=10, lw=0),\n", - " Line2D([0], [0], c=\"gold\", marker=\"o\", markersize=10, lw=0),\n", - " Line2D(\n", - " [0],\n", - " [0],\n", - " c=color_ocean,\n", - " marker=\"o\",\n", - " markersize=10,\n", - " markeredgecolor=\"k\",\n", - " lw=0,\n", - " ),\n", - " Line2D(\n", - " [0], [0], c=color_land, marker=\"o\", markersize=10, markeredgecolor=\"k\", lw=0\n", - " ),\n", - " ]\n", - " ax.legend(\n", - " custom_lines,\n", - " [\n", - " \"basic RK4\",\n", - " \"displacement\",\n", - " \"partial slip\",\n", - " \"free slip\",\n", - " \"ocean point\",\n", - " \"land point\",\n", - " ],\n", - " bbox_to_anchor=(0.01, 0.8),\n", - " loc=\"center left\",\n", - " borderaxespad=0.0,\n", - " framealpha=1,\n", - " )" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Figure 8** shows the influence of the different solutions on the particle trajectories near the shore. **Subfigure 8B** shows how the different solutions make trajectory 0 move along the shore and around the corner of the model geometry. **Subfigure 8C** shows how trajectories are unaffected by the different interpolation scheme as long as they do not cross a coastal gridcell.\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "parcels", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/example_brownian.py b/docs/user_guide/examples_v3/example_brownian.py deleted file mode 100644 index 7d53b6fd89..0000000000 --- a/docs/user_guide/examples_v3/example_brownian.py +++ /dev/null @@ -1,64 +0,0 @@ -import random -from datetime import timedelta - -import numpy as np -import pytest - -import parcels - - -def mesh_conversion(mesh): - return (1852.0 * 60) if mesh == "spherical" else 1.0 - - -@pytest.mark.parametrize("mesh", ["flat", "spherical"]) -def test_brownian_example(mesh, npart=3000): - fieldset = parcels.FieldSet.from_data( - {"U": 0, "V": 0}, {"lon": 0, "lat": 0}, mesh=mesh - ) - - # Set diffusion constants. - kh_zonal = 100 # in m^2/s - kh_meridional = 100 # in m^2/s - - # Create field of constant Kh_zonal and Kh_meridional - fieldset.add_field(parcels.Field("Kh_zonal", kh_zonal, lon=0, lat=0, mesh=mesh)) - fieldset.add_field( - parcels.Field("Kh_meridional", kh_meridional, lon=0, lat=0, mesh=mesh) - ) - - # Set random seed - random.seed(123456) - - runtime = timedelta(days=1) - - random.seed(1234) - pset = parcels.ParticleSet( - fieldset=fieldset, - pclass=parcels.Particle, - lon=np.zeros(npart), - lat=np.zeros(npart), - ) - pset.execute( - pset.Kernel(parcels.kernels.DiffusionUniformKh), - runtime=runtime, - dt=timedelta(hours=1), - ) - - expected_std_x = np.sqrt(2 * kh_zonal * runtime.total_seconds()) - expected_std_y = np.sqrt(2 * kh_meridional * runtime.total_seconds()) - - ys = pset.lat * mesh_conversion(mesh) - xs = pset.lon * mesh_conversion( - mesh - ) # since near equator, we do not need to care about curvature effect - - tol = 250 # 250m tolerance - assert np.allclose(np.std(xs), expected_std_x, atol=tol) - assert np.allclose(np.std(ys), expected_std_y, atol=tol) - assert np.allclose(np.mean(xs), 0, atol=tol) - assert np.allclose(np.mean(ys), 0, atol=tol) - - -if __name__ == "__main__": - test_brownian_example("spherical", npart=2000) diff --git a/docs/user_guide/examples_v3/example_decaying_moving_eddy.py b/docs/user_guide/examples_v3/example_decaying_moving_eddy.py deleted file mode 100644 index 01e6c401d4..0000000000 --- a/docs/user_guide/examples_v3/example_decaying_moving_eddy.py +++ /dev/null @@ -1,109 +0,0 @@ -from datetime import timedelta - -import numpy as np - -import parcels - -# Define some constants. -u_g = 0.04 # Geostrophic current -u_0 = 0.3 # Initial speed in x dirrection. v_0 = 0 -gamma = ( - 1.0 / timedelta(days=2.89).total_seconds() -) # Dissipitave effects due to viscousity. -gamma_g = 1.0 / timedelta(days=28.9).total_seconds() -f = 1.0e-4 # Coriolis parameter. -start_lon = [10000.0] # Define the start longitude and latitude for the particle. -start_lat = [10000.0] - - -def decaying_moving_eddy_fieldset( - xdim=2, ydim=2 -): # Define 2D flat, square fieldset for testing purposes. - """Simulate an ocean that accelerates subject to Coriolis force - and dissipative effects, upon which a geostrophic current is - superimposed. - - The original test description can be found in: N. Fabbroni, 2009, - Numerical Simulation of Passive tracers dispersion in the sea, - Ph.D. dissertation, University of Bologna - http://amsdottorato.unibo.it/1733/1/Fabbroni_Nicoletta_Tesi.pdf - """ - depth = np.zeros(1, dtype=np.float32) - time = np.arange(0.0, 2.0 * 86400.0 + 1e-5, 60.0 * 5.0, dtype=np.float64) - lon = np.linspace(0, 20000, xdim, dtype=np.float32) - lat = np.linspace(5000, 12000, ydim, dtype=np.float32) - - U = np.zeros((time.size, lat.size, lon.size), dtype=np.float32) - V = np.zeros((time.size, lat.size, lon.size), dtype=np.float32) - - for t in range(time.size): - U[t, :, :] = u_g * np.exp(-gamma_g * time[t]) + (u_0 - u_g) * np.exp( - -gamma * time[t] - ) * np.cos(f * time[t]) - V[t, :, :] = -(u_0 - u_g) * np.exp(-gamma * time[t]) * np.sin(f * time[t]) - - data = {"U": U, "V": V} - dimensions = {"lon": lon, "lat": lat, "depth": depth, "time": time} - return parcels.FieldSet.from_data(data, dimensions, mesh="flat") - - -def true_values( - t, x_0, y_0 -): # Calculate the expected values for particles at the endtime, given their start location. - x = ( - x_0 - + (u_g / gamma_g) * (1 - np.exp(-gamma_g * t)) - + f - * ((u_0 - u_g) / (f**2 + gamma**2)) - * ( - (gamma / f) - + np.exp(-gamma * t) * (np.sin(f * t) - (gamma / f) * np.cos(f * t)) - ) - ) - y = y_0 - ((u_0 - u_g) / (f**2 + gamma**2)) * f * ( - 1 - np.exp(-gamma * t) * (np.cos(f * t) + (gamma / f) * np.sin(f * t)) - ) - - return np.array([x, y]) - - -def decaying_moving_example(fieldset, outfile, method=parcels.kernels.AdvectionRK4): - pset = parcels.ParticleSet( - fieldset, pclass=parcels.Particle, lon=start_lon, lat=start_lat - ) - - dt = timedelta(minutes=5) - runtime = timedelta(days=2) - outputdt = timedelta(hours=1) - - pset.execute( - method, - runtime=runtime, - dt=dt, - output_file=pset.ParticleFile(name=outfile, outputdt=outputdt), - ) - - return pset - - -def test_rotation_example(tmpdir): - outfile = tmpdir.join("DecayingMovingParticle.zarr") - fieldset = decaying_moving_eddy_fieldset() - pset = decaying_moving_example(fieldset, outfile) - vals = true_values( - pset[0].time, start_lon, start_lat - ) # Calculate values for the particle. - assert np.allclose( - np.array([[pset[0].lon], [pset[0].lat]]), vals, 1e-2 - ) # Check advected values against calculated values. - - -def main(): - outfile = "DecayingMovingParticle.zarr" - fieldset = decaying_moving_eddy_fieldset() - - decaying_moving_example(fieldset, outfile) - - -if __name__ == "__main__": - main() diff --git a/docs/user_guide/examples_v3/example_globcurrent.py b/docs/user_guide/examples_v3/example_globcurrent.py deleted file mode 100755 index 622adea810..0000000000 --- a/docs/user_guide/examples_v3/example_globcurrent.py +++ /dev/null @@ -1,275 +0,0 @@ -from datetime import timedelta -from glob import glob - -import numpy as np -import pytest -import xarray as xr - -import parcels - - -def set_globcurrent_fieldset( - filename=None, -): - if filename is None: - data_folder = parcels.download_example_dataset("GlobCurrent_example_data") - filename = str( - data_folder / "2002*-GLOBCURRENT-L4-CUReul_hs-ALT_SUM-v02.0-fv01.0.nc" - ) - variables = { - "U": "eastward_eulerian_current_velocity", - "V": "northward_eulerian_current_velocity", - } - dimensions = {"lat": "lat", "lon": "lon", "time": "time"} - ds = xr.open_mfdataset(filename, combine="by_coords") - - return parcels.FieldSet.from_xarray_dataset( - ds, - variables, - dimensions, - ) - - -@pytest.mark.parametrize( - "dt, lonstart, latstart", [(3600.0, 25, -35), (-3600.0, 20, -39)] -) -def test_globcurrent_fieldset_advancetime(dt, lonstart, latstart): - data_folder = parcels.download_example_dataset("GlobCurrent_example_data") - basepath = str(data_folder / "20*-GLOBCURRENT-L4-CUReul_hs-ALT_SUM-v02.0-fv01.0.nc") - files = sorted(glob(str(basepath))) - - fieldsetsub = set_globcurrent_fieldset(files[0:10]) - psetsub = parcels.ParticleSet.from_list( - fieldset=fieldsetsub, - pclass=parcels.Particle, - lon=[lonstart], - lat=[latstart], - ) - - fieldsetall = set_globcurrent_fieldset(files[0:10]) - psetall = parcels.ParticleSet.from_list( - fieldset=fieldsetall, - pclass=parcels.Particle, - lon=[lonstart], - lat=[latstart], - ) - if dt < 0: - psetsub[0].time_nextloop = fieldsetsub.U.grid.time[-1] - psetall[0].time_nextloop = fieldsetall.U.grid.time[-1] - - psetsub.execute(parcels.kernels.AdvectionRK4, runtime=timedelta(days=7), dt=dt) - psetall.execute(parcels.kernels.AdvectionRK4, runtime=timedelta(days=7), dt=dt) - - assert abs(psetsub[0].lon - psetall[0].lon) < 1e-4 - - -def test_globcurrent_particles(): - fieldset = set_globcurrent_fieldset() - - lonstart = [25] - latstart = [-35] - - pset = parcels.ParticleSet( - fieldset, pclass=parcels.Particle, lon=lonstart, lat=latstart - ) - - pset.execute( - parcels.kernels.AdvectionRK4, runtime=timedelta(days=1), dt=timedelta(minutes=5) - ) - - assert abs(pset[0].lon - 23.8) < 1 - assert abs(pset[0].lat - -35.3) < 1 - - -def test__particles_init_time(): - fieldset = set_globcurrent_fieldset() - - lonstart = [25] - latstart = [-35] - - # tests the different ways of initialising the time of a particle - pset = parcels.ParticleSet( - fieldset, - pclass=parcels.Particle, - lon=lonstart, - lat=latstart, - time=np.datetime64("2002-01-15"), - ) - pset2 = parcels.ParticleSet( - fieldset, - pclass=parcels.Particle, - lon=lonstart, - lat=latstart, - time=14 * 86400, - ) - pset3 = parcels.ParticleSet( - fieldset, - pclass=parcels.Particle, - lon=lonstart, - lat=latstart, - time=np.array([np.datetime64("2002-01-15")]), - ) - pset4 = parcels.ParticleSet( - fieldset, - pclass=parcels.Particle, - lon=lonstart, - lat=latstart, - time=[np.datetime64("2002-01-15")], - ) - assert pset[0].time - pset2[0].time == 0 - assert pset[0].time - pset3[0].time == 0 - assert pset[0].time - pset4[0].time == 0 - - -def test_globcurrent_outside_time_interval_error(): - fieldset = set_globcurrent_fieldset() - pset = parcels.ParticleSet( - fieldset, - pclass=parcels.Particle, - lon=[25], - lat=[-35], - time=fieldset.U.grid.time[0] - timedelta(days=1).total_seconds(), - ) - with pytest.raises(parcels.OutsideTimeInterval): - pset.execute( - parcels.kernels.AdvectionRK4, - runtime=timedelta(days=1), - dt=timedelta(minutes=5), - ) - - -@pytest.mark.v4alpha -@pytest.mark.skip( - reason="This was always broken when using eager loading `deferred_load=False` for the P field. Needs to be fixed." -) -@pytest.mark.parametrize("dt", [-300, 300]) -@pytest.mark.parametrize("with_starttime", [True, False]) -def test_globcurrent_startparticles_between_time_arrays(dt, with_starttime): - """Test for correctly initialising particle start times. - - When using Fields with different temporal domains, its important to intialise particles - at the beginning of the time period where all Fields have available data (i.e., the - intersection of the temporal domains) - """ - fieldset = set_globcurrent_fieldset() - - data_folder = parcels.download_example_dataset("GlobCurrent_example_data") - fnamesFeb = sorted(glob(f"{data_folder}/200202*.nc")) - fieldset.add_field( - parcels.Field.from_netcdf( - fnamesFeb, - ("P", "eastward_eulerian_current_velocity"), - {"lat": "lat", "lon": "lon", "time": "time"}, - ) - ) - - MyParticle = parcels.Particle.add_variable("sample_var", initial=0.0) - - def SampleP(particle, fieldset, time): # pragma: no cover - particle.sample_var += fieldset.P[ - time, particle.depth, particle.lat, particle.lon - ] - - if with_starttime: - time = fieldset.U.grid.time[0] if dt > 0 else fieldset.U.grid.time[-1] - pset = parcels.ParticleSet( - fieldset, pclass=MyParticle, lon=[25], lat=[-35], time=time - ) - else: - pset = parcels.ParticleSet(fieldset, pclass=MyParticle, lon=[25], lat=[-35]) - - if with_starttime: - with pytest.raises(parcels.OutsideTimeInterval): - pset.execute( - pset.Kernel(parcels.kernels.AdvectionRK4) + SampleP, - runtime=timedelta(days=1), - dt=dt, - ) - else: - pset.execute( - pset.Kernel(parcels.kernels.AdvectionRK4) + SampleP, - runtime=timedelta(days=1), - dt=dt, - ) - - -def test_globcurrent_particle_independence(rundays=5): - fieldset = set_globcurrent_fieldset() - time0 = fieldset.U.grid.time[0] - - def DeleteP0(particle, fieldset, time): # pragma: no cover - if particle.trajectory == 0: - particle.delete() - - pset0 = parcels.ParticleSet( - fieldset, pclass=parcels.Particle, lon=[25, 25], lat=[-35, -35], time=time0 - ) - - pset0.execute( - pset0.Kernel(DeleteP0) + parcels.kernels.AdvectionRK4, - runtime=timedelta(days=rundays), - dt=timedelta(minutes=5), - ) - - pset1 = parcels.ParticleSet( - fieldset, pclass=parcels.Particle, lon=[25, 25], lat=[-35, -35], time=time0 - ) - - pset1.execute( - parcels.kernels.AdvectionRK4, - runtime=timedelta(days=rundays), - dt=timedelta(minutes=5), - ) - - assert np.allclose([pset0[-1].lon, pset0[-1].lat], [pset1[-1].lon, pset1[-1].lat]) - - -@pytest.mark.parametrize("dt", [-300, 300]) -@pytest.mark.parametrize("pid_offset", [0, 20]) -def test_globcurrent_pset_fromfile(dt, pid_offset, tmpdir): - filename = tmpdir.join("pset_fromparticlefile.zarr") - fieldset = set_globcurrent_fieldset() - - parcels.Particle.setLastID(pid_offset) - pset = parcels.ParticleSet(fieldset, pclass=parcels.Particle, lon=25, lat=-35) - pfile = pset.ParticleFile(filename, outputdt=timedelta(hours=6)) - pset.execute( - parcels.kernels.AdvectionRK4, - runtime=timedelta(days=1), - dt=dt, - output_file=pfile, - ) - pfile.write_latest_locations(pset, max(pset.time_nextloop)) - - restarttime = np.nanmax if dt > 0 else np.nanmin - pset_new = parcels.ParticleSet.from_particlefile( - fieldset, - pclass=parcels.Particle, - filename=filename, - restarttime=restarttime, - ) - pset.execute(parcels.kernels.AdvectionRK4, runtime=timedelta(days=1), dt=dt) - pset_new.execute(parcels.kernels.AdvectionRK4, runtime=timedelta(days=1), dt=dt) - - for var in ["lon", "lat", "depth", "time", "trajectory"]: - assert np.allclose( - [getattr(p, var) for p in pset], [getattr(p, var) for p in pset_new] - ) - - -def test_error_outputdt_not_multiple_dt(tmpdir): - # Test that outputdt is a multiple of dt - fieldset = set_globcurrent_fieldset() - - filepath = tmpdir.join("pfile_error_outputdt_not_multiple_dt.zarr") - - dt = 81.2584344538292 # number for which output writing fails - - pset = parcels.ParticleSet(fieldset, pclass=parcels.Particle, lon=[0], lat=[0]) - ofile = pset.ParticleFile(name=filepath, outputdt=timedelta(days=1)) - - def DoNothing(particle, fieldset, time): # pragma: no cover - pass - - with pytest.raises(ValueError): - pset.execute(DoNothing, runtime=timedelta(days=10), dt=dt, output_file=ofile) diff --git a/docs/user_guide/examples_v3/example_mitgcm.py b/docs/user_guide/examples_v3/example_mitgcm.py deleted file mode 100644 index 56d8e2bebd..0000000000 --- a/docs/user_guide/examples_v3/example_mitgcm.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import timedelta -from pathlib import Path - -import pytest - -import parcels - - -def run_mitgcm_zonally_reentrant(path: Path): - """Function that shows how to load MITgcm data in a zonally periodic domain.""" - data_folder = parcels.download_example_dataset("MITgcm_example_data") - filenames = { - "U": f"{data_folder}/mitgcm_UV_surface_zonally_reentrant.nc", - "V": f"{data_folder}/mitgcm_UV_surface_zonally_reentrant.nc", - } - variables = {"U": "UVEL", "V": "VVEL"} - dimensions = { - "U": {"lon": "XG", "lat": "YG", "time": "time"}, - "V": {"lon": "XG", "lat": "YG", "time": "time"}, - } - fieldset = parcels.FieldSet.from_mitgcm( - filenames, variables, dimensions, mesh="flat" - ) - - fieldset.add_periodic_halo(zonal=True) - fieldset.add_constant("domain_width", 1000000) - - def periodicBC(particle, fieldset, time): # pragma: no cover - if particle.lon < 0: - particle.dlon += fieldset.domain_width - elif particle.lon > fieldset.domain_width: - particle.dlon -= fieldset.domain_width - - # Release particles 5 cells away from the Eastern boundary - pset = parcels.ParticleSet.from_line( - fieldset, - pclass=parcels.Particle, - start=(fieldset.U.grid.lon[-5], fieldset.U.grid.lat[5]), - finish=(fieldset.U.grid.lon[-5], fieldset.U.grid.lat[-5]), - size=10, - ) - pfile = parcels.ParticleFile( - str(path), - pset, - outputdt=timedelta(days=1), - chunks=(len(pset), 1), - ) - kernels = parcels.kernels.AdvectionRK4 + pset.Kernel(periodicBC) - pset.execute( - kernels, runtime=timedelta(days=5), dt=timedelta(minutes=30), output_file=pfile - ) - - -@pytest.mark.v4alpha -@pytest.mark.xfail(reason="Test uses add_periodic_halo(). Update to not use it.") -def test_mitgcm_output(tmpdir): - def get_path() -> Path: - return tmpdir / "MIT_particles.zarr" - - run_mitgcm_zonally_reentrant(get_path()) diff --git a/docs/user_guide/examples_v3/example_moving_eddies.py b/docs/user_guide/examples_v3/example_moving_eddies.py deleted file mode 100644 index 348e21b328..0000000000 --- a/docs/user_guide/examples_v3/example_moving_eddies.py +++ /dev/null @@ -1,364 +0,0 @@ -import math -from argparse import ArgumentParser -from datetime import timedelta - -import numpy as np -import pytest - -import parcels - -method = { - "RK4": parcels.kernels.AdvectionRK4, - "EE": parcels.kernels.AdvectionEE, - "RK45": parcels.kernels.AdvectionRK45, -} - - -def moving_eddies_fieldset(xdim=200, ydim=350, mesh="flat"): - """Generate a fieldset encapsulating the flow field consisting of two - moving eddies, one moving westward and the other moving northwestward. - - Parameters - ---------- - xdim : - Horizontal dimension of the generated fieldset (Default value = 200) - xdim : - Vertical dimension of the generated fieldset (Default value = 200) - mesh : str - String indicating the type of mesh coordinates used during velocity interpolation: - - 1. spherical: Lat and lon in degree, with a - correction for zonal velocity U near the poles. - 2. flat (default): No conversion, lat/lon are assumed to be in m. - ydim : - (Default value = 350) - - - Notes - ----- - Note that this is not a proper geophysical flow. Rather, a Gaussian eddy is moved - artificially with uniform velocities. Velocities are calculated from geostrophy. - - """ - # Set Parcels FieldSet variables - time = np.arange(0.0, 8.0 * 86400.0, 86400.0, dtype=np.float64) - - # Coordinates of the test fieldset (on A-grid in m) - if mesh == "spherical": - lon = np.linspace(0, 4, xdim, dtype=np.float32) - lat = np.linspace(45, 52, ydim, dtype=np.float32) - else: - lon = np.linspace(0, 4.0e5, xdim, dtype=np.float32) - lat = np.linspace(0, 7.0e5, ydim, dtype=np.float32) - - # Grid spacing in m - def cosd(x): - return math.cos(math.radians(float(x))) - - dx = ( - (lon[1] - lon[0]) * 1852 * 60 * cosd(lat.mean()) - if mesh == "spherical" - else lon[1] - lon[0] - ) - dy = (lat[1] - lat[0]) * 1852 * 60 if mesh == "spherical" else lat[1] - lat[0] - - # Define arrays U (zonal), V (meridional), and P (sea surface height) on A-grid - U = np.zeros((time.size, lat.size, lon.size), dtype=np.float32) - V = np.zeros((time.size, lat.size, lon.size), dtype=np.float32) - P = np.zeros((time.size, lat.size, lon.size), dtype=np.float32) - - # Some constants - corio_0 = 1.0e-4 # Coriolis parameter - h0 = 1 # Max eddy height - sig = 0.5 # Eddy e-folding decay scale (in degrees) - g = 10 # Gravitational constant - eddyspeed = 0.1 # Translational speed in m/s - dX = eddyspeed * 86400 / dx # Grid cell movement of eddy max each day - dY = eddyspeed * 86400 / dy # Grid cell movement of eddy max each day - - [y, x] = np.mgrid[: lat.size, : lon.size] - for t in range(time.size): - hymax_1 = lat.size / 7.0 - hxmax_1 = 0.75 * lon.size - dX * t - hymax_2 = 3.0 * lat.size / 7.0 + dY * t - hxmax_2 = 0.75 * lon.size - dX * t - - P[t, :, :] = h0 * np.exp( - -((x - hxmax_1) ** 2) / (sig * lon.size / 4.0) ** 2 - - (y - hymax_1) ** 2 / (sig * lat.size / 7.0) ** 2 - ) - P[t, :, :] += h0 * np.exp( - -((x - hxmax_2) ** 2) / (sig * lon.size / 4.0) ** 2 - - (y - hymax_2) ** 2 / (sig * lat.size / 7.0) ** 2 - ) - - V[t, :, :-1] = -np.diff(P[t, :, :], axis=1) / dx / corio_0 * g - V[t, :, -1] = V[t, :, -2] # Fill in the last column - - U[t, :-1, :] = np.diff(P[t, :, :], axis=0) / dy / corio_0 * g - U[t, -1, :] = U[t, -2, :] # Fill in the last row - - data = {"U": U, "V": V, "P": P} - dimensions = {"lon": lon, "lat": lat, "time": time} - - fieldset = parcels.FieldSet.from_data(data, dimensions, mesh=mesh) - - # setting some constants for AdvectionRK45 kernel - fieldset.RK45_min_dt = 1e-3 - fieldset.RK45_max_dt = 1e2 - fieldset.RK45_tol = 1e-5 - return fieldset - - -def moving_eddies_example( - fieldset, outfile, npart=2, verbose=False, method=parcels.kernels.AdvectionRK4 -): - """Configuration of a particle set that follows two moving eddies. - - - Parameters - ---------- - fieldset : - :class FieldSet: that defines the flow field - outfile : - - npart : - Number of particles to initialise. (Default value = 2) - verbose : - (Default value = False) - method : - (Default value = AdvectionRK4) - """ - start = (3.3, 46.0) if fieldset.U.grid.mesh == "spherical" else (3.3e5, 1e5) - finish = (3.3, 47.8) if fieldset.U.grid.mesh == "spherical" else (3.3e5, 2.8e5) - pset = parcels.ParticleSet.from_line( - fieldset=fieldset, - size=npart, - pclass=parcels.Particle, - start=start, - finish=finish, - ) - - if verbose: - print(f"Initial particle positions:\n{pset}") - - # Execute for 1 week, with 1 hour timesteps and hourly output - runtime = timedelta(days=7) - print(f"MovingEddies: Advecting {npart} particles for {runtime}") - pset.execute( - method, - runtime=runtime, - dt=timedelta(hours=1), - output_file=pset.ParticleFile(name=outfile, outputdt=timedelta(hours=1)), - ) - - if verbose: - print(f"Final particle positions:\n{pset}") - - return pset - - -@pytest.mark.parametrize("mesh", ["flat", "spherical"]) -def test_moving_eddies_fwdbwd(mesh, tmpdir, npart=2): - method = parcels.kernels.AdvectionRK4 - fieldset = moving_eddies_fieldset(mesh=mesh) - - lons = [3.3, 3.3] if fieldset.U.grid.mesh == "spherical" else [3.3e5, 3.3e5] - lats = [46.0, 47.8] if fieldset.U.grid.mesh == "spherical" else [1e5, 2.8e5] - pset = parcels.ParticleSet( - fieldset=fieldset, pclass=parcels.Particle, lon=lons, lat=lats - ) - - # Execte for 14 days, with 30sec timesteps and hourly output - runtime = timedelta(days=1) - dt = timedelta(minutes=5) - outputdt = timedelta(hours=1) - print(f"MovingEddies: Advecting {npart} particles for {runtime}") - outfile = tmpdir.join("EddyParticlefwd") - pset.execute( - method, - runtime=runtime, - dt=dt, - output_file=pset.ParticleFile(name=outfile, outputdt=outputdt), - ) - - print("Now running in backward time mode") - outfile = tmpdir.join("EddyParticlebwd") - pset.execute( - method, - endtime=0, - dt=-dt, - output_file=pset.ParticleFile(name=outfile, outputdt=outputdt), - ) - - # Also include last timestep - for var in ["lon", "lat", "depth", "time"]: - pset.particledata.setallvardata( - f"{var}", pset.particledata.getvardata(f"{var}_nextloop") - ) - - assert np.allclose(pset.lon, lons) - assert np.allclose(pset.lat, lats) - - -@pytest.mark.parametrize("mesh", ["flat", "spherical"]) -def test_moving_eddies_fieldset(mesh, tmpdir): - fieldset = moving_eddies_fieldset(mesh=mesh) - outfile = tmpdir.join("EddyParticle") - pset = moving_eddies_example(fieldset, outfile, 2) - # Also include last timestep - for var in ["lon", "lat", "depth", "time"]: - pset.particledata.setallvardata( - f"{var}", pset.particledata.getvardata(f"{var}_nextloop") - ) - if mesh == "flat": - assert pset[0].lon < 2.2e5 and 1.1e5 < pset[0].lat < 1.2e5 - assert pset[1].lon < 2.2e5 and 3.7e5 < pset[1].lat < 3.8e5 - else: - assert pset[0].lon < 2.0 and 46.2 < pset[0].lat < 46.25 - assert pset[1].lon < 2.0 and 48.8 < pset[1].lat < 48.85 - - -def fieldsetfile(mesh, tmpdir): - """Generate fieldset files for moving_eddies test.""" - filename = tmpdir.join("moving_eddies") - fieldset = moving_eddies_fieldset(200, 350, mesh=mesh) - fieldset.write(filename) - return filename - - -def test_moving_eddies_file(tmpdir): - data_folder = parcels.download_example_dataset("MovingEddies_data") - filenames = { - "U": str(data_folder / "moving_eddiesU.nc"), - "V": str(data_folder / "moving_eddiesV.nc"), - "P": str(data_folder / "moving_eddiesP.nc"), - } - variables = {"U": "vozocrtx", "V": "vomecrty", "P": "P"} - dimensions = {"lon": "nav_lon", "lat": "nav_lat", "time": "time_counter"} - fieldset = parcels.FieldSet.from_netcdf(filenames, variables, dimensions) - outfile = tmpdir.join("EddyParticle") - pset = moving_eddies_example(fieldset, outfile, 2) - # Also include last timestep - for var in ["lon", "lat", "depth", "time"]: - pset.particledata.setallvardata( - f"{var}", pset.particledata.getvardata(f"{var}_nextloop") - ) - assert pset[0].lon < 2.2e5 and 1.1e5 < pset[0].lat < 1.2e5 - assert pset[1].lon < 2.2e5 and 3.7e5 < pset[1].lat < 3.8e5 - - -@pytest.mark.v4alpha -@pytest.mark.xfail( - reason="Calls fieldset.add_periodic_halo(). In v4, interpolation should work without adding halo." -) -def test_periodic_and_computeTimeChunk_eddies(): - data_folder = parcels.download_example_dataset("MovingEddies_data") - filename = str(data_folder / "moving_eddies") - - fieldset = parcels.FieldSet.from_parcels(filename) - fieldset.add_constant("halo_west", fieldset.U.grid.lon[0]) - fieldset.add_constant("halo_east", fieldset.U.grid.lon[-1]) - fieldset.add_constant("halo_south", fieldset.U.grid.lat[0]) - fieldset.add_constant("halo_north", fieldset.U.grid.lat[-1]) - fieldset.add_periodic_halo(zonal=True, meridional=True) - pset = parcels.ParticleSet.from_list( - fieldset=fieldset, - pclass=parcels.Particle, - lon=[3.3, 3.3], - lat=[46.0, 47.8], - ) - - def periodicBC(particle, fieldset, time): # pragma: no cover - if particle.lon < fieldset.halo_west: - particle.dlon += fieldset.halo_east - fieldset.halo_west - elif particle.lon > fieldset.halo_east: - particle.dlon -= fieldset.halo_east - fieldset.halo_west - if particle.lat < fieldset.halo_south: - particle.dlat += fieldset.halo_north - fieldset.halo_south - elif particle.lat > fieldset.halo_north: - particle.dlat -= fieldset.halo_north - fieldset.halo_south - - def slowlySouthWestward(particle, fieldset, time): # pragma: no cover - particle.dlon -= 5 * particle.dt / 1e5 - particle.dlat -= 3 * particle.dt / 1e5 - - kernels = ( - pset.Kernel(parcels.kernels.AdvectionRK4) + slowlySouthWestward + periodicBC - ) - pset.execute(kernels, runtime=timedelta(days=6), dt=timedelta(hours=1)) - - -def main(args=None): - p = ArgumentParser( - description=""" -Example of particle advection around an idealised peninsula""" - ) - p.add_argument( - "-p", "--particles", type=int, default=2, help="Number of particles to advect" - ) - p.add_argument( - "-v", - "--verbose", - action="store_true", - default=False, - help="Print particle information before and after execution", - ) - p.add_argument( - "--profiling", - action="store_true", - default=False, - help="Print profiling information after run", - ) - p.add_argument( - "-f", - "--fieldset", - type=int, - nargs=2, - default=None, - help="Generate fieldset file with given dimensions", - ) - p.add_argument( - "-m", - "--method", - choices=("RK4", "EE", "RK45"), - default="RK4", - help="Numerical method used for advection", - ) - args = p.parse_args(args) - data_folder = parcels.download_example_dataset("MovingEddies_data") - filename = str(data_folder / "moving_eddies") - - # Generate fieldset files according to given dimensions - if args.fieldset is not None: - fieldset = moving_eddies_fieldset( - args.fieldset[0], args.fieldset[1], mesh="flat" - ) - else: - fieldset = moving_eddies_fieldset(mesh="flat") - outfile = "EddyParticle" - - if args.profiling: - from cProfile import runctx - from pstats import Stats - - runctx( - "moving_eddies_example(fieldset, outfile, args.particles, \ - verbose=args.verbose, method=method[args.method])", - globals(), - locals(), - "Profile.prof", - ) - Stats("Profile.prof").strip_dirs().sort_stats("time").print_stats(10) - else: - moving_eddies_example( - fieldset, - outfile, - args.particles, - verbose=args.verbose, - method=method[args.method], - ) - - -if __name__ == "__main__": - main() diff --git a/docs/user_guide/examples_v3/example_nemo_curvilinear.py b/docs/user_guide/examples_v3/example_nemo_curvilinear.py deleted file mode 100644 index d39d798a78..0000000000 --- a/docs/user_guide/examples_v3/example_nemo_curvilinear.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Example script that runs a set of particles in a NEMO curvilinear grid.""" - -from datetime import timedelta -from glob import glob - -import numpy as np -import pytest - -import parcels - -advection = { - "RK4": parcels.kernels.AdvectionRK4, - "AA": parcels.kernels.AdvectionAnalytical, -} - - -def run_nemo_curvilinear(outfile, advtype="RK4"): - """Run parcels on the NEMO curvilinear grid.""" - data_folder = parcels.download_example_dataset("NemoCurvilinear_data") - - filenames = { - "U": { - "lon": f"{data_folder}/mesh_mask.nc4", - "lat": f"{data_folder}/mesh_mask.nc4", - "data": f"{data_folder}/U_purely_zonal-ORCA025_grid_U.nc4", - }, - "V": { - "lon": f"{data_folder}/mesh_mask.nc4", - "lat": f"{data_folder}/mesh_mask.nc4", - "data": f"{data_folder}/V_purely_zonal-ORCA025_grid_V.nc4", - }, - } - variables = {"U": "U", "V": "V"} - dimensions = {"lon": "glamf", "lat": "gphif"} - fieldset = parcels.FieldSet.from_nemo(filenames, variables, dimensions) - - # Now run particles as normal - npart = 20 - lonp = 30 * np.ones(npart) - if advtype == "RK4": - latp = np.linspace(-70, 88, npart) - runtime = timedelta(days=160) - else: - latp = np.linspace(-70, 70, npart) - runtime = timedelta(days=15) - - def periodicBC(particle, fieldSet, time): # pragma: no cover - if particle.lon > 180: - particle.dlon -= 360 - - pset = parcels.ParticleSet.from_list(fieldset, parcels.Particle, lon=lonp, lat=latp) - pfile = parcels.ParticleFile(outfile, pset, outputdt=timedelta(days=1)) - kernels = pset.Kernel(advection[advtype]) + periodicBC - pset.execute(kernels, runtime=runtime, dt=timedelta(hours=6), output_file=pfile) - assert np.allclose(pset.lat - latp, 0, atol=1e-1) - - -def test_nemo_curvilinear(tmpdir): - """Test the NEMO curvilinear example.""" - outfile = tmpdir.join("nemo_particles") - run_nemo_curvilinear(outfile) - - -def test_nemo_curvilinear_AA(tmpdir): - """Test the NEMO curvilinear example with analytical advection.""" - outfile = tmpdir.join("nemo_particlesAA") - run_nemo_curvilinear(outfile, "AA") - - -@pytest.mark.v4alpha -@pytest.mark.xfail( - reason="The method for checking whether fields are on the same grid is going to change in v4 (i.e., not by looking at the dataFiles attribute)." -) -def test_nemo_3D_samegrid(): - """Test that the same grid is used for U and V in 3D NEMO fields.""" - data_folder = parcels.download_example_dataset("NemoNorthSeaORCA025-N006_data") - ufiles = sorted(glob(f"{data_folder}/ORCA*U.nc")) - vfiles = sorted(glob(f"{data_folder}/ORCA*V.nc")) - wfiles = sorted(glob(f"{data_folder}/ORCA*W.nc")) - mesh_mask = f"{data_folder}/coordinates.nc" - - filenames = { - "U": {"lon": mesh_mask, "lat": mesh_mask, "depth": wfiles[0], "data": ufiles}, - "V": {"lon": mesh_mask, "lat": mesh_mask, "depth": wfiles[0], "data": vfiles}, - "W": {"lon": mesh_mask, "lat": mesh_mask, "depth": wfiles[0], "data": wfiles}, - } - - variables = {"U": "uo", "V": "vo", "W": "wo"} - dimensions = { - "U": { - "lon": "glamf", - "lat": "gphif", - "depth": "depthw", - "time": "time_counter", - }, - "V": { - "lon": "glamf", - "lat": "gphif", - "depth": "depthw", - "time": "time_counter", - }, - "W": { - "lon": "glamf", - "lat": "gphif", - "depth": "depthw", - "time": "time_counter", - }, - } - - fieldset = parcels.FieldSet.from_nemo(filenames, variables, dimensions) - - assert fieldset.U._dataFiles is not fieldset.W._dataFiles - - -def main(): - run_nemo_curvilinear("nemo_particles") - - -if __name__ == "__main__": - main() diff --git a/docs/user_guide/examples_v3/example_ofam.py b/docs/user_guide/examples_v3/example_ofam.py deleted file mode 100644 index 4b68011d35..0000000000 --- a/docs/user_guide/examples_v3/example_ofam.py +++ /dev/null @@ -1,89 +0,0 @@ -import gc -from datetime import timedelta - -import numpy as np -import pytest -import xarray as xr - -import parcels - - -def set_ofam_fieldset(use_xarray=False): - data_folder = parcels.download_example_dataset("OFAM_example_data") - filenames = { - "U": f"{data_folder}/OFAM_simple_U.nc", - "V": f"{data_folder}/OFAM_simple_V.nc", - } - variables = {"U": "u", "V": "v"} - dimensions = { - "lat": "yu_ocean", - "lon": "xu_ocean", - "depth": "st_ocean", - "time": "Time", - } - if use_xarray: - ds = xr.open_mfdataset([filenames["U"], filenames["V"]], combine="by_coords") - return parcels.FieldSet.from_xarray_dataset( - ds, variables, dimensions, allow_time_extrapolation=True - ) - else: - return parcels.FieldSet.from_netcdf( - filenames, - variables, - dimensions, - allow_time_extrapolation=True, - ) - - -@pytest.mark.parametrize("use_xarray", [True, False]) -def test_ofam_fieldset_fillvalues(use_xarray): - fieldset = set_ofam_fieldset(use_xarray=use_xarray) - # V.data[0, 0, 150] is a landpoint, that makes NetCDF4 generate a masked array, instead of an ndarray - assert fieldset.V.data[0, 0, 150] == 0 - - -@pytest.mark.parametrize("dt", [timedelta(minutes=-5), timedelta(minutes=5)]) -def test_ofam_xarray_vs_netcdf(dt): - fieldsetNetcdf = set_ofam_fieldset(use_xarray=False) - fieldsetxarray = set_ofam_fieldset(use_xarray=True) - lonstart, latstart, runtime = (180, 10, timedelta(days=7)) - - psetN = parcels.ParticleSet( - fieldsetNetcdf, pclass=parcels.Particle, lon=lonstart, lat=latstart - ) - psetN.execute(parcels.kernels.AdvectionRK4, runtime=runtime, dt=dt) - - psetX = parcels.ParticleSet( - fieldsetxarray, pclass=parcels.Particle, lon=lonstart, lat=latstart - ) - psetX.execute(parcels.kernels.AdvectionRK4, runtime=runtime, dt=dt) - - assert np.allclose(psetN[0].lon, psetX[0].lon) - assert np.allclose(psetN[0].lat, psetX[0].lat) - - -@pytest.mark.parametrize("use_xarray", [True, False]) -def test_ofam_particles(use_xarray): - gc.collect() - fieldset = set_ofam_fieldset(use_xarray=use_xarray) - - lonstart = [180] - latstart = [10] - depstart = [2.5] # the depth of the first layer in OFAM - - pset = parcels.ParticleSet( - fieldset, - pclass=parcels.Particle, - lon=lonstart, - lat=latstart, - depth=depstart, - ) - - pset.execute( - parcels.kernels.AdvectionRK4, - runtime=timedelta(days=10), - dt=timedelta(minutes=5), - ) - - assert abs(pset[0].lon - 173) < 1 - assert abs(pset[0].lat - 11) < 1 diff --git a/docs/user_guide/examples_v3/example_peninsula.py b/docs/user_guide/examples_v3/example_peninsula.py deleted file mode 100644 index cccfdd7ce7..0000000000 --- a/docs/user_guide/examples_v3/example_peninsula.py +++ /dev/null @@ -1,321 +0,0 @@ -import math # NOQA -from argparse import ArgumentParser -from datetime import timedelta - -import numpy as np -import pytest - -import parcels - -method = { - "RK4": parcels.kernels.AdvectionRK4, - "EE": parcels.kernels.AdvectionEE, - "RK45": parcels.kernels.AdvectionRK45, -} - - -def peninsula_fieldset(xdim, ydim, mesh="flat", grid_type="A"): - """Construct a fieldset encapsulating the flow field around an idealised peninsula. - - Parameters - ---------- - xdim : - Horizontal dimension of the generated fieldset - xdim : - Vertical dimension of the generated fieldset - mesh : str - String indicating the type of mesh coordinates used during velocity interpolation: - - 1. spherical: Lat and lon in degree, with a - correction for zonal velocity U near the poles. - 2. flat (default): No conversion, lat/lon are assumed to be in m. - grid_type : - Option whether grid is either Arakawa A (default) or C - - The original test description can be found in Fig. 2.2.3 in: - North, E. W., Gallego, A., Petitgas, P. (Eds). 2009. Manual of - recommended practices for modelling physical - biological - interactions during fish early life. - ICES Cooperative Research Report No. 295. 111 pp. - http://archimer.ifremer.fr/doc/00157/26792/24888.pdf - ydim : - - - """ - # Set Parcels FieldSet variables - - # Generate the original test setup on A-grid in m - domainsizeX, domainsizeY = (1.0e5, 5.0e4) - La = np.linspace(1e3, domainsizeX, xdim, dtype=np.float32) - Wa = np.linspace(1e3, domainsizeY, ydim, dtype=np.float32) - - u0 = 1 - x0 = domainsizeX / 2 - R = 0.32 * domainsizeX / 2 - - # Create the fields - x, y = np.meshgrid(La, Wa, sparse=True, indexing="xy") - P = u0 * R**2 * y / ((x - x0) ** 2 + y**2) - u0 * y - - # Set land points to zero - landpoints = P >= 0.0 - P[landpoints] = 0.0 - - if grid_type == "A": - U = u0 - u0 * R**2 * ((x - x0) ** 2 - y**2) / (((x - x0) ** 2 + y**2) ** 2) - V = -2 * u0 * R**2 * ((x - x0) * y) / (((x - x0) ** 2 + y**2) ** 2) - U[landpoints] = 0.0 - V[landpoints] = 0.0 - elif grid_type == "C": - U = np.zeros(P.shape) - V = np.zeros(P.shape) - V[:, 1:] = (P[:, 1:] - P[:, :-1]) / (La[1] - La[0]) - U[1:, :] = -(P[1:, :] - P[:-1, :]) / (Wa[1] - Wa[0]) - else: - raise RuntimeError(f"Grid_type {grid_type} is not a valid option") - - # Convert from m to lat/lon for spherical meshes - lon = La / 1852.0 / 60.0 if mesh == "spherical" else La - lat = Wa / 1852.0 / 60.0 if mesh == "spherical" else Wa - - data = {"U": U, "V": V, "P": P} - dimensions = {"lon": lon, "lat": lat} - - fieldset = parcels.FieldSet.from_data(data, dimensions, mesh=mesh) - if grid_type == "C": - fieldset.U.interp_method = "cgrid_velocity" - fieldset.V.interp_method = "cgrid_velocity" - return fieldset - - -def UpdateP(particle, fieldset, time): # pragma: no cover - if time == 0: - particle.p_start = fieldset.P[time, particle.depth, particle.lat, particle.lon] - particle.p = fieldset.P[time, particle.depth, particle.lat, particle.lon] - - -def peninsula_example( - fieldset, - outfile, - npart, - degree=1, - verbose=False, - output=True, - method=parcels.kernels.AdvectionRK4, -): - """Example configuration of particle flow around an idealised Peninsula - - Parameters - ---------- - fieldset : - - outfile : str - Basename of the input fieldset. - npart : int - Number of particles to intialise. - degree : - (Default value = 1) - verbose : - (Default value = False) - output : - (Default value = True) - method : - (Default value = AdvectionRK4) - - """ - # First, we define a custom Particle class to which we add a - # custom variable, the initial stream function value p. - MyParticle = parcels.Particle.add_variable( - [ - parcels.Variable("p", dtype=np.float32, initial=0.0), - parcels.Variable("p_start", dtype=np.float32, initial=0), - ] - ) - - # Initialise particles - if fieldset.U.grid.mesh == "flat": - x = 3000 # 3 km offset from boundary - else: - x = 3.0 * (1.0 / 1.852 / 60) # 3 km offset from boundary - y = ( - fieldset.U.lat[0] + x, - fieldset.U.lat[-1] - x, - ) # latitude range, including offsets - pset = parcels.ParticleSet.from_line( - fieldset, - size=npart, - pclass=MyParticle, - start=(x, y[0]), - finish=(x, y[1]), - time=0, - ) - - if verbose: - print(f"Initial particle positions:\n{pset}") - - # Advect the particles for 24h - time = timedelta(hours=24) - dt = timedelta(minutes=5) - k_adv = pset.Kernel(method) - k_p = pset.Kernel(UpdateP) - out = ( - pset.ParticleFile(name=outfile, outputdt=timedelta(hours=1)) if output else None - ) - print(f"Peninsula: Advecting {npart} particles for {time}") - pset.execute(k_adv + k_p, runtime=time, dt=dt, output_file=out) - - if verbose: - print(f"Final particle positions:\n{pset}") - - return pset - - -@pytest.mark.parametrize("mesh", ["flat", "spherical"]) -def test_peninsula_fieldset(mesh, tmpdir): - """Execute peninsula test from fieldset generated in memory.""" - fieldset = peninsula_fieldset(100, 50, mesh) - outfile = tmpdir.join("Peninsula") - pset = peninsula_example(fieldset, outfile, 5, degree=1) - # Test advection accuracy by comparing streamline values - err_adv = np.abs(pset.p_start - pset.p) - assert (err_adv <= 1.0).all() - # Test Field sampling accuracy by comparing kernel against Field sampling - err_smpl = np.array( - [ - abs( - pset.p[i] - - pset.fieldset.P[0.0, pset.depth[i], pset.lat[i], pset.lon[i]] - ) - for i in range(pset.size) - ] - ) - assert (err_smpl <= 1.0).all() - - -@pytest.mark.parametrize("mesh", ["flat", "spherical"]) -def test_peninsula_fieldset_AnalyticalAdvection(mesh, tmpdir): - """Execute peninsula test using Analytical Advection on C grid.""" - fieldset = peninsula_fieldset(101, 51, "flat", grid_type="C") - outfile = tmpdir.join("PeninsulaAA") - pset = peninsula_example( - fieldset, outfile, npart=10, method=parcels.kernels.AdvectionAnalytical - ) - # Test advection accuracy by comparing streamline values - err_adv = np.array([abs(p.p_start - p.p) for p in pset]) - - assert (err_adv <= 3.0e2).all() - - -def test_peninsula_file(tmpdir): - """Open fieldset files and execute.""" - data_folder = parcels.download_example_dataset("Peninsula_data") - filenames = { - "U": str(data_folder / "peninsulaU.nc"), - "V": str(data_folder / "peninsulaV.nc"), - "P": str(data_folder / "peninsulaP.nc"), - } - variables = {"U": "vozocrtx", "V": "vomecrty", "P": "P"} - dimensions = {"lon": "nav_lon", "lat": "nav_lat", "time": "time_counter"} - fieldset = parcels.FieldSet.from_netcdf( - filenames, variables, dimensions, allow_time_extrapolation=True - ) - outfile = tmpdir.join("Peninsula") - pset = peninsula_example(fieldset, outfile, 5, degree=1) - # Test advection accuracy by comparing streamline values - err_adv = np.abs(pset.p_start - pset.p) - assert (err_adv <= 1.0).all() - # Test Field sampling accuracy by comparing kernel against Field sampling - err_smpl = np.array( - [ - abs( - pset.p[i] - - pset.fieldset.P[0.0, pset.depth[i], pset.lat[i], pset.lon[i]] - ) - for i in range(pset.size) - ] - ) - assert (err_smpl <= 1.0).all() - - -def main(args=None): - p = ArgumentParser( - description=""" -Example of particle advection around an idealised peninsula""" - ) - p.add_argument( - "-p", "--particles", type=int, default=20, help="Number of particles to advect" - ) - p.add_argument( - "-d", "--degree", type=int, default=1, help="Degree of spatial interpolation" - ) - p.add_argument( - "-v", - "--verbose", - action="store_true", - default=False, - help="Print particle information before and after execution", - ) - p.add_argument( - "-o", - "--nooutput", - action="store_true", - default=False, - help="Suppress trajectory output", - ) - p.add_argument( - "--profiling", - action="store_true", - default=False, - help="Print profiling information after run", - ) - p.add_argument( - "-f", - "--fieldset", - type=int, - nargs=2, - default=None, - help="Generate fieldset file with given dimensions", - ) - p.add_argument( - "-m", - "--method", - choices=("RK4", "EE", "RK45"), - default="RK4", - help="Numerical method used for advection", - ) - args = p.parse_args(args) - - if args.fieldset is not None: - fieldset = peninsula_fieldset(args.fieldset[0], args.fieldset[1], mesh="flat") - else: - fieldset = peninsula_fieldset(100, 50, mesh="flat") - - outfile = "Peninsula" - - if args.profiling: - from cProfile import runctx - from pstats import Stats - - runctx( - "peninsula_example(fieldset, outfile, args.particles,\ - degree=args.degree, verbose=args.verbose,\ - output=not args.nooutput, method=method[args.method])", - globals(), - locals(), - "Profile.prof", - ) - Stats("Profile.prof").strip_dirs().sort_stats("time").print_stats(10) - else: - peninsula_example( - fieldset, - outfile, - args.particles, - degree=args.degree, - verbose=args.verbose, - output=not args.nooutput, - method=method[args.method], - ) - - -if __name__ == "__main__": - main() diff --git a/docs/user_guide/examples_v3/example_radial_rotation.py b/docs/user_guide/examples_v3/example_radial_rotation.py deleted file mode 100644 index b433c270e4..0000000000 --- a/docs/user_guide/examples_v3/example_radial_rotation.py +++ /dev/null @@ -1,95 +0,0 @@ -import math -from datetime import timedelta - -import numpy as np - -import parcels - - -def radial_rotation_fieldset( - xdim=200, ydim=200 -): # Define 2D flat, square fieldset for testing purposes. - lon = np.linspace(0, 60, xdim, dtype=np.float32) - lat = np.linspace(0, 60, ydim, dtype=np.float32) - - x0 = 30.0 # Define the origin to be the centre of the Field. - y0 = 30.0 - - U = np.zeros((ydim, xdim), dtype=np.float32) - V = np.zeros((ydim, xdim), dtype=np.float32) - - T = timedelta(days=1) - omega = 2 * np.pi / T.total_seconds() # Define the rotational period as 1 day. - - for i in range(lon.size): - for j in range(lat.size): - r = np.sqrt( - (lon[i] - x0) ** 2 + (lat[j] - y0) ** 2 - ) # Define radial displacement. - assert r >= 0.0 - assert r <= np.sqrt(x0**2 + y0**2) - - theta = math.atan2((lat[j] - y0), (lon[i] - x0)) # Define the polar angle. - assert abs(theta) <= np.pi - - U[j, i] = r * math.sin(theta) * omega - V[j, i] = -r * math.cos(theta) * omega - - data = {"U": U, "V": V} - dimensions = {"lon": lon, "lat": lat} - return parcels.FieldSet.from_data(data, dimensions, mesh="flat") - - -def true_values(age): # Calculate the expected values for particle 2 at the endtime. - x = 20 * math.sin(2 * np.pi * age / (24.0 * 60.0**2)) + 30.0 - y = 20 * math.cos(2 * np.pi * age / (24.0 * 60.0**2)) + 30.0 - - return [x, y] - - -def rotation_example(fieldset, outfile, method=parcels.kernels.AdvectionRK4): - npart = 2 # Test two particles on the rotating fieldset. - pset = parcels.ParticleSet.from_line( - fieldset, - size=npart, - pclass=parcels.Particle, - start=(30.0, 30.0), - finish=(30.0, 50.0), - ) # One particle in centre, one on periphery of Field. - - runtime = timedelta(hours=17) - dt = timedelta(minutes=5) - outputdt = timedelta(hours=1) - - pset.execute( - method, - runtime=runtime, - dt=dt, - output_file=pset.ParticleFile(name=outfile, outputdt=outputdt), - ) - - return pset - - -def test_rotation_example(tmpdir): - fieldset = radial_rotation_fieldset() - outfile = tmpdir.join("RadialParticle") - pset = rotation_example(fieldset, outfile) - assert ( - pset[0].lon == 30.0 and pset[0].lat == 30.0 - ) # Particle at centre of Field remains stationary. - vals = true_values(pset.time[1]) - assert np.allclose( - pset[1].lon, vals[0], 1e-5 - ) # Check advected values against calculated values. - assert np.allclose(pset[1].lat, vals[1], 1e-5) - - -def main(): - fieldset = radial_rotation_fieldset() - outfile = "RadialParticle" - rotation_example(fieldset, outfile) - - -if __name__ == "__main__": - main() diff --git a/docs/user_guide/examples_v3/example_stommel.py b/docs/user_guide/examples_v3/example_stommel.py deleted file mode 100755 index 068ac5812a..0000000000 --- a/docs/user_guide/examples_v3/example_stommel.py +++ /dev/null @@ -1,248 +0,0 @@ -import math -from argparse import ArgumentParser -from datetime import timedelta - -import numpy as np -import pytest - -import parcels - -method = { - "RK4": parcels.kernels.AdvectionRK4, - "EE": parcels.kernels.AdvectionEE, - "RK45": parcels.kernels.AdvectionRK45, -} - - -def stommel_fieldset(xdim=200, ydim=200, grid_type="A"): - """Simulate a periodic current along a western boundary, with significantly - larger velocities along the western edge than the rest of the region - - The original test description can be found in: N. Fabbroni, 2009, - Numerical Simulation of Passive tracers dispersion in the sea, - Ph.D. dissertation, University of Bologna - http://amsdottorato.unibo.it/1733/1/Fabbroni_Nicoletta_Tesi.pdf - """ - a = b = 10000 * 1e3 - scalefac = 0.05 # to scale for physically meaningful velocities - dx, dy = a / xdim, b / ydim - - # Coordinates of the test fieldset (on A-grid in deg) - lon = np.linspace(0, a, xdim, dtype=np.float32) - lat = np.linspace(0, b, ydim, dtype=np.float32) - - # Define arrays U (zonal), V (meridional) and P (sea surface height) - U = np.zeros((lat.size, lon.size), dtype=np.float32) - V = np.zeros((lat.size, lon.size), dtype=np.float32) - P = np.zeros((lat.size, lon.size), dtype=np.float32) - - beta = 2e-11 - r = 1 / (11.6 * 86400) - es = r / (beta * a) - - for j in range(lat.size): - for i in range(lon.size): - xi = lon[i] / a - yi = lat[j] / b - P[j, i] = ( - (1 - math.exp(-xi / es) - xi) - * math.pi - * np.sin(math.pi * yi) - * scalefac - ) - if grid_type == "A": - U[j, i] = ( - -(1 - math.exp(-xi / es) - xi) - * math.pi**2 - * np.cos(math.pi * yi) - * scalefac - ) - V[j, i] = ( - (math.exp(-xi / es) / es - 1) - * math.pi - * np.sin(math.pi * yi) - * scalefac - ) - if grid_type == "C": - V[:, 1:] = (P[:, 1:] - P[:, 0:-1]) / dx * a - U[1:, :] = -(P[1:, :] - P[0:-1, :]) / dy * b - - data = {"U": U, "V": V, "P": P} - dimensions = {"lon": lon, "lat": lat} - fieldset = parcels.FieldSet.from_data(data, dimensions, mesh="flat") - if grid_type == "C": - fieldset.U.interp_method = "cgrid_velocity" - fieldset.V.interp_method = "cgrid_velocity" - return fieldset - - -def UpdateP(particle, fieldset, time): # pragma: no cover - if time == 0: - particle.p_start = fieldset.P[time, particle.depth, particle.lat, particle.lon] - particle.p = fieldset.P[time, particle.depth, particle.lat, particle.lon] - - -def AgeP(particle, fieldset, time): # pragma: no cover - particle.age += particle.dt - if particle.age > fieldset.maxage: - particle.delete() - - -def stommel_example( - npart=1, - verbose=False, - method=parcels.kernels.AdvectionRK4, - grid_type="A", - outfile="StommelParticle.zarr", - repeatdt=None, - maxage=None, -): - parcels.timer.fieldset = parcels.timer.Timer( - "FieldSet", parent=parcels.timer.stommel - ) - fieldset = stommel_fieldset(grid_type=grid_type) - parcels.timer.fieldset.stop() - - parcels.timer.pset = parcels.timer.Timer("Pset", parent=parcels.timer.stommel) - parcels.timer.psetinit = parcels.timer.Timer("Pset_init", parent=parcels.timer.pset) - - # Execute for 600 days, with 1-hour timesteps and 5-day output - runtime = timedelta(days=600) - dt = timedelta(hours=1) - outputdt = timedelta(days=5) - - extra_vars = [ - parcels.Variable("p", dtype=np.float32, initial=0.0), - parcels.Variable("p_start", dtype=np.float32, initial=0.0), - parcels.Variable("next_dt", dtype=np.float64, initial=dt.total_seconds()), - parcels.Variable("age", dtype=np.float32, initial=0.0), - ] - MyParticle = parcels.Particle.add_variables(extra_vars) - - pset = parcels.ParticleSet.from_line( - fieldset, - size=npart, - pclass=MyParticle, - repeatdt=repeatdt, - start=(10e3, 5000e3), - finish=(100e3, 5000e3), - time=0, - ) - - if verbose: - print(f"Initial particle positions:\n{pset}") - - maxage = runtime.total_seconds() if maxage is None else maxage - fieldset.add_constant("maxage", maxage) - print(f"Stommel: Advecting {npart} particles for {runtime}") - parcels.timer.psetinit.stop() - parcels.timer.psetrun = parcels.timer.Timer("Pset_run", parent=parcels.timer.pset) - pset.execute( - method + pset.Kernel(UpdateP) + pset.Kernel(AgeP), - runtime=runtime, - dt=dt, - output_file=pset.ParticleFile(name=outfile, outputdt=outputdt), - ) - - if verbose: - print(f"Final particle positions:\n{pset}") - parcels.timer.psetrun.stop() - parcels.timer.pset.stop() - - return pset - - -@pytest.mark.parametrize("grid_type", ["A", "C"]) -def test_stommel_fieldset(grid_type, tmpdir): - parcels.timer.root = parcels.timer.Timer("Main") - parcels.timer.stommel = parcels.timer.Timer("Stommel", parent=parcels.timer.root) - outfile = tmpdir.join("StommelParticle") - psetRK4 = stommel_example( - 1, - method=method["RK4"], - grid_type=grid_type, - outfile=outfile, - ) - psetRK45 = stommel_example( - 1, - method=method["RK45"], - grid_type=grid_type, - outfile=outfile, - ) - assert np.allclose(psetRK4.lon, psetRK45.lon, rtol=1e-3) - assert np.allclose(psetRK4.lat, psetRK45.lat, rtol=1.1e-3) - err_adv = np.abs(psetRK4.p_start - psetRK4.p) - assert (err_adv <= 1.0e-1).all() - err_smpl = np.array( - [ - abs( - psetRK4.p[i] - - psetRK4.fieldset.P[ - 0.0, psetRK4.lon[i], psetRK4.lat[i], psetRK4.depth[i] - ] - ) - for i in range(psetRK4.size) - ] - ) - assert (err_smpl <= 1.0e-1).all() - parcels.timer.stommel.stop() - parcels.timer.root.stop() - parcels.timer.root.print_tree() - - -def main(args=None): - parcels.timer.root = parcels.timer.Timer("Main") - parcels.timer.args = parcels.timer.Timer("Args", parent=parcels.timer.root) - p = ArgumentParser( - description=""" -Example of particle advection in the steady-state solution of the Stommel equation""" - ) - p.add_argument( - "-p", "--particles", type=int, default=1, help="Number of particles to advect" - ) - p.add_argument( - "-v", - "--verbose", - action="store_true", - default=False, - help="Print particle information before and after execution", - ) - p.add_argument( - "-m", - "--method", - choices=("RK4", "EE", "RK45"), - default="RK4", - help="Numerical method used for advection", - ) - p.add_argument( - "-o", "--outfile", default="StommelParticle.zarr", help="Name of output file" - ) - p.add_argument( - "-r", "--repeatdt", default=None, type=int, help="repeatdt of the ParticleSet" - ) - p.add_argument( - "-a", - "--maxage", - default=None, - type=int, - help="max age of the particles (after which particles are deleted)", - ) - args = p.parse_args(args) - - parcels.timer.args.stop() - parcels.timer.stommel = parcels.timer.Timer("Stommel", parent=parcels.timer.root) - stommel_example( - args.particles, - verbose=args.verbose, - method=method[args.method], - outfile=args.outfile, - repeatdt=args.repeatdt, - maxage=args.maxage, - ) - parcels.timer.stommel.stop() - parcels.timer.root.stop() - parcels.timer.root.print_tree() - - -if __name__ == "__main__": - main() diff --git a/docs/user_guide/examples_v3/tutorial_analyticaladvection.ipynb b/docs/user_guide/examples_v3/tutorial_analyticaladvection.ipynb deleted file mode 100644 index cbf14aa229..0000000000 --- a/docs/user_guide/examples_v3/tutorial_analyticaladvection.ipynb +++ /dev/null @@ -1,635 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Analytical advection\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "While [Lagrangian Ocean Analysis](https://www.sciencedirect.com/science/article/pii/S1463500317301853) has been around since at least the 1980s, the [Blanke and Raynaud (1997)](https://journals.ametsoc.org/doi/full/10.1175/1520-0485%281997%29027%3C1038%3AKOTPEU%3E2.0.CO%3B2) paper has really spurred the use of Lagrangian particles for large-scale simulations. In their 1997 paper, Blanke and Raynaud introduce the so-called _Analytical Advection_ scheme for pathway integration. This scheme has been the base for the [Ariane](https://ariane-code.cnrs.fr/whatsariane.html) and [TRACMASS](http://www.tracmass.org/) tools. We have also implemented it in Parcels, particularly to facilitate comparison with for example the Runge-Kutta integration scheme.\n", - "\n", - "In this tutorial, we will briefly explain what the scheme is and how it can be used in Parcels. For more information, see for example [Döös et al (2017)](https://www.geosci-model-dev.net/10/1733/2017/).\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that the Analytical scheme works with a few limitations:\n", - "\n", - "1. The velocity field should be defined on a C-grid (see also the [Parcels NEMO tutorial](https://docs.oceanparcels.org/en/latest/examples/tutorial_nemo_3D.html)).\n", - "\n", - "And specifically for the implementation in Parcels \n", - "\n", - "2. The `AdvectionAnalytical` kernel only works for `Scipy Particles`. \n", - "3. Since Analytical Advection does not use timestepping, the `dt` parameter in `pset.execute()` should be set to `np.inf`. For backward-in-time simulations, it should be set to `-np.inf`. \n", - "4. For time-varying fields, only the 'intermediate timesteps' scheme ([section 2.3 of Döös et al 2017](https://www.geosci-model-dev.net/10/1733/2017/gmd-10-1733-2017.pdf)) is implemented. While there is also a way to also analytically solve the time-evolving fields ([section 2.4 of Döös et al 2017](https://www.geosci-model-dev.net/10/1733/2017/gmd-10-1733-2017.pdf)), this is not yet implemented in Parcels.\n", - "\n", - "We welcome contributions to the further development of this algorithm and in particular the analytical time-varying case. See [here](https://github.com/OceanParcels/parcels/blob/main/parcels/application_kernels/advection.py) for the code of the `AdvectionAnalytical` kernel.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Below, we will show how this `AdvectionAnalytical` kernel performs on one idealised time-constant flow and two idealised time-varying flows: a radial rotation, the time-varying double-gyre as implemented in e.g. [Froyland and Padberg (2009)](https://www.sciencedirect.com/science/article/abs/pii/S0167278909000803) and the Bickley Jet as implemented in e.g. [Hadjighasem et al (2017)](https://aip.scitation.org/doi/10.1063/1.4982720).\n", - "\n", - "First import the relevant modules.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from datetime import timedelta\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import xarray as xr\n", - "from IPython.display import HTML\n", - "from matplotlib.animation import FuncAnimation\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Radial rotation example\n", - "\n", - "As in [Figure 4a of Lange and Van Sebille (2017)](https://doi.org/10.5194/gmd-10-4175-2017), we define a circular flow with period 24 hours, on a C-grid\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def radialrotation_fieldset(xdim=201, ydim=201):\n", - " # Coordinates of the test fieldset (on C-grid in m)\n", - " a = b = 20000 # domain size\n", - " lon = np.linspace(-a / 2, a / 2, xdim, dtype=np.float32)\n", - " lat = np.linspace(-b / 2, b / 2, ydim, dtype=np.float32)\n", - " dx, dy = lon[2] - lon[1], lat[2] - lat[1]\n", - "\n", - " # Define arrays R (radius), U (zonal velocity) and V (meridional velocity)\n", - " U = np.zeros((lat.size, lon.size), dtype=np.float32)\n", - " V = np.zeros((lat.size, lon.size), dtype=np.float32)\n", - " R = np.zeros((lat.size, lon.size), dtype=np.float32)\n", - "\n", - " def calc_r_phi(ln, lt):\n", - " return np.sqrt(ln**2 + lt**2), np.arctan2(ln, lt)\n", - "\n", - " omega = 2 * np.pi / timedelta(days=1).total_seconds()\n", - " for i in range(lon.size):\n", - " for j in range(lat.size):\n", - " r, phi = calc_r_phi(lon[i], lat[j])\n", - " R[j, i] = r\n", - " r, phi = calc_r_phi(lon[i] - dx / 2, lat[j])\n", - " V[j, i] = -omega * r * np.sin(phi)\n", - " r, phi = calc_r_phi(lon[i], lat[j] - dy / 2)\n", - " U[j, i] = omega * r * np.cos(phi)\n", - "\n", - " data = {\"U\": U, \"V\": V, \"R\": R}\n", - " dimensions = {\"lon\": lon, \"lat\": lat}\n", - " fieldset = parcels.FieldSet.from_data(data, dimensions, mesh=\"flat\")\n", - " fieldset.U.interp_method = \"cgrid_velocity\"\n", - " fieldset.V.interp_method = \"cgrid_velocity\"\n", - " return fieldset\n", - "\n", - "\n", - "fieldsetRR = radialrotation_fieldset()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now simulate a set of particles on this fieldset, using the `AdvectionAnalytical` kernel. Keep track of how the radius of the Particle trajectory changes during the run.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def UpdateR(particle, fieldset, time):\n", - " if time == 0:\n", - " particle.radius_start = fieldset.R[\n", - " time, particle.depth, particle.lat, particle.lon\n", - " ]\n", - " particle.radius = fieldset.R[time, particle.depth, particle.lat, particle.lon]\n", - "\n", - "\n", - "MyParticle = parcels.Particle.add_variables(\n", - " [\n", - " parcels.Variable(\"radius\", dtype=np.float32, initial=0.0),\n", - " parcels.Variable(\"radius_start\", dtype=np.float32, initial=0.0),\n", - " ]\n", - ")\n", - "\n", - "\n", - "pset = parcels.ParticleSet(fieldsetRR, pclass=MyParticle, lon=0, lat=4e3, time=0)\n", - "\n", - "output = pset.ParticleFile(name=\"radialAnalytical.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "pset.execute(\n", - " pset.Kernel(UpdateR) + parcels.kernels.AdvectionAnalytical,\n", - " runtime=timedelta(hours=25),\n", - " dt=timedelta(hours=1), # needs to be the same as outputdt for Analytical Advection\n", - " output_file=output,\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now plot the trajectory and calculate how much the radius has changed during the run.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds = xr.open_zarr(\"radialAnalytical.zarr\")\n", - "plt.plot(ds.lon.T, ds.lat.T, \".-\")\n", - "\n", - "print(f\"Particle radius at start of run {pset.radius_start[0]}\")\n", - "print(f\"Particle radius at end of run {pset.radius[0]}\")\n", - "print(f\"Change in Particle radius {pset.radius[0] - pset.radius_start[0]}\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Double-gyre example\n", - "\n", - "Define a double gyre fieldset that varies in time\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def doublegyre_fieldset(times, xdim=51, ydim=51):\n", - " \"\"\"Implemented following Froyland and Padberg (2009)\n", - " 10.1016/j.physd.2009.03.002\"\"\"\n", - " A = 0.25\n", - " delta = 0.25\n", - " omega = 2 * np.pi\n", - "\n", - " a, b = 2, 1 # domain size\n", - " lon = np.linspace(0, a, xdim, dtype=np.float32)\n", - " lat = np.linspace(0, b, ydim, dtype=np.float32)\n", - " dx, dy = lon[2] - lon[1], lat[2] - lat[1]\n", - "\n", - " U = np.zeros((times.size, lat.size, lon.size), dtype=np.float32)\n", - " V = np.zeros((times.size, lat.size, lon.size), dtype=np.float32)\n", - "\n", - " for i in range(lon.size):\n", - " for j in range(lat.size):\n", - " x1 = lon[i] - dx / 2\n", - " x2 = lat[j] - dy / 2\n", - " for t in range(len(times)):\n", - " time = times[t]\n", - " f = (\n", - " delta * np.sin(omega * time) * x1**2\n", - " + (1 - 2 * delta * np.sin(omega * time)) * x1\n", - " )\n", - " U[t, j, i] = -np.pi * A * np.sin(np.pi * f) * np.cos(np.pi * x2)\n", - " V[t, j, i] = (\n", - " np.pi\n", - " * A\n", - " * np.cos(np.pi * f)\n", - " * np.sin(np.pi * x2)\n", - " * (\n", - " 2 * delta * np.sin(omega * time) * x1\n", - " + 1\n", - " - 2 * delta * np.sin(omega * time)\n", - " )\n", - " )\n", - "\n", - " data = {\"U\": U, \"V\": V}\n", - " dimensions = {\"lon\": lon, \"lat\": lat, \"time\": times}\n", - " allow_time_extrapolation = True if len(times) == 1 else False\n", - " fieldset = parcels.FieldSet.from_data(\n", - " data, dimensions, mesh=\"flat\", allow_time_extrapolation=allow_time_extrapolation\n", - " )\n", - " fieldset.U.interp_method = \"cgrid_velocity\"\n", - " fieldset.V.interp_method = \"cgrid_velocity\"\n", - " return fieldset\n", - "\n", - "\n", - "fieldsetDG = doublegyre_fieldset(times=np.arange(0, 3.1, 0.1))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now simulate a set of particles on this fieldset, using the `AdvectionAnalytical` kernel\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "X, Y = np.meshgrid(np.arange(0.15, 1.85, 0.1), np.arange(0.15, 0.85, 0.1))\n", - "\n", - "psetAA = parcels.ParticleSet(fieldsetDG, pclass=parcels.Particle, lon=X, lat=Y)\n", - "\n", - "output = psetAA.ParticleFile(name=\"doublegyreAA.zarr\", outputdt=0.1)\n", - "\n", - "psetAA.execute(\n", - " parcels.kernels.AdvectionAnalytical,\n", - " dt=0.1, # needs to be the same as outputdt for Analytical Advection\n", - " runtime=3,\n", - " output_file=output,\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And then show the particle trajectories in an animation\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%capture\n", - "\n", - "ds = xr.open_zarr(\"doublegyreAA.zarr\")\n", - "\n", - "fig = plt.figure(figsize=(7, 5), constrained_layout=True)\n", - "ax = fig.add_subplot()\n", - "\n", - "ax.set_ylabel(\"Meridional distance [m]\")\n", - "ax.set_xlabel(\"Zonal distance [m]\")\n", - "ax.set_xlim(0, 2)\n", - "ax.set_ylim(0, 1)\n", - "\n", - "timerange = np.unique(ds[\"time\"].values[np.isfinite(ds[\"time\"])])\n", - "\n", - "# Indices of the data where time = 0\n", - "time_id = np.where(ds[\"time\"] == timerange[0])\n", - "\n", - "sc = ax.scatter(ds[\"lon\"].values[time_id], ds[\"lat\"].values[time_id], c=\"b\")\n", - "\n", - "t = str(timerange[0].astype(\"timedelta64[ms]\"))\n", - "title = ax.set_title(f\"Particles at t = {t}\")\n", - "\n", - "\n", - "def animate(i):\n", - " t = str(timerange[i].astype(\"timedelta64[ms]\"))\n", - " title.set_text(f\"Particles at t = {t}\")\n", - "\n", - " time_id = np.where(ds[\"time\"] == timerange[i])\n", - " sc.set_offsets(np.c_[ds[\"lon\"].values[time_id], ds[\"lat\"].values[time_id]])\n", - "\n", - "\n", - "anim = FuncAnimation(fig, animate, frames=len(timerange), interval=100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "HTML(anim.to_jshtml())" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now, we can also compute these trajectories with the `AdvectionRK4` kernel\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "psetRK4 = parcels.ParticleSet(fieldsetDG, pclass=parcels.Particle, lon=X, lat=Y)\n", - "psetRK4.execute(parcels.kernels.AdvectionRK4, dt=0.01, runtime=3)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And we can then compare the final locations of the particles from the `AdvectionRK4` and `AdvectionAnalytical` simulations\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.plot(psetRK4.lon, psetRK4.lat, \"r.\", label=\"RK4\")\n", - "plt.plot(psetAA.lon, psetAA.lat, \"b.\", label=\"Analytical\")\n", - "plt.legend()\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The final locations are similar, but not exactly the same. Because everything else is the same, the difference has to be due to the different kernels. Which one is more correct, however, can't be determined from this analysis alone.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Bickley Jet example\n", - "\n", - "Let's as a second example, do a similar analysis for a Bickley Jet, as detailed in e.g. [Hadjighasem et al (2017)](https://aip.scitation.org/doi/10.1063/1.4982720).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def bickleyjet_fieldset(times, xdim=51, ydim=51):\n", - " \"\"\"Bickley Jet Field as implemented in Hadjighasem et al 2017,\n", - " 10.1063/1.4982720\"\"\"\n", - " U0 = 0.06266\n", - " L = 1770.0\n", - " r0 = 6371.0\n", - " k1 = 2 * 1 / r0\n", - " k2 = 2 * 2 / r0\n", - " k3 = 2 * 3 / r0\n", - " eps1 = 0.075\n", - " eps2 = 0.4\n", - " eps3 = 0.3\n", - " c3 = 0.461 * U0\n", - " c2 = 0.205 * U0\n", - " c1 = c3 + ((np.sqrt(5) - 1) / 2.0) * (k2 / k1) * (c2 - c3)\n", - "\n", - " a, b = np.pi * r0, 7000.0 # domain size\n", - " lon = np.linspace(0, a, xdim, dtype=np.float32)\n", - " lat = np.linspace(-b / 2, b / 2, ydim, dtype=np.float32)\n", - " dx, dy = lon[2] - lon[1], lat[2] - lat[1]\n", - "\n", - " U = np.zeros((times.size, lat.size, lon.size), dtype=np.float32)\n", - " V = np.zeros((times.size, lat.size, lon.size), dtype=np.float32)\n", - "\n", - " for i in range(lon.size):\n", - " for j in range(lat.size):\n", - " x1 = lon[i] - dx / 2\n", - " x2 = lat[j] - dy / 2\n", - " for t in range(len(times)):\n", - " time = times[t]\n", - "\n", - " f1 = eps1 * np.exp(-1j * k1 * c1 * time)\n", - " f2 = eps2 * np.exp(-1j * k2 * c2 * time)\n", - " f3 = eps3 * np.exp(-1j * k3 * c3 * time)\n", - " F1 = f1 * np.exp(1j * k1 * x1)\n", - " F2 = f2 * np.exp(1j * k2 * x1)\n", - " F3 = f3 * np.exp(1j * k3 * x1)\n", - " G = np.real(np.sum([F1, F2, F3]))\n", - " G_x = np.real(np.sum([1j * k1 * F1, 1j * k2 * F2, 1j * k3 * F3]))\n", - " U[t, j, i] = (\n", - " U0 / (np.cosh(x2 / L) ** 2)\n", - " + 2 * U0 * np.sinh(x2 / L) / (np.cosh(x2 / L) ** 3) * G\n", - " )\n", - " V[t, j, i] = U0 * L * (1.0 / np.cosh(x2 / L)) ** 2 * G_x\n", - "\n", - " data = {\"U\": U, \"V\": V}\n", - " dimensions = {\"lon\": lon, \"lat\": lat, \"time\": times}\n", - " allow_time_extrapolation = True if len(times) == 1 else False\n", - " fieldset = parcels.FieldSet.from_data(\n", - " data, dimensions, mesh=\"flat\", allow_time_extrapolation=allow_time_extrapolation\n", - " )\n", - " fieldset.U.interp_method = \"cgrid_velocity\"\n", - " fieldset.V.interp_method = \"cgrid_velocity\"\n", - " return fieldset\n", - "\n", - "\n", - "fieldsetBJ = bickleyjet_fieldset(times=np.arange(0, 1.1, 0.1) * 86400)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Add a zonal halo for periodic boundary conditions in the zonal direction\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldsetBJ.add_constant(\n", - " \"halo_west\", fieldsetBJ.U.grid.lon[1]\n", - ") # TODO v4: Change index back to 0 when periodic interpolation is done\n", - "fieldsetBJ.add_constant(\n", - " \"halo_east\", fieldsetBJ.U.grid.lon[-2]\n", - ") # TODO v4: Change index back to -1 when periodic interpolation is done\n", - "\n", - "\n", - "def ZonalBC(particle, fieldset, time):\n", - " if particle.lon < fieldset.halo_west:\n", - " particle_dlon += fieldset.halo_east - fieldset.halo_west\n", - " elif particle.lon > fieldset.halo_east:\n", - " particle_dlon -= fieldset.halo_east - fieldset.halo_west" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And simulate a set of particles on this fieldset, using the `AdvectionAnalytical` kernel\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "X, Y = np.meshgrid(np.arange(0, 19900, 100), np.arange(-100, 100, 100))\n", - "\n", - "psetAA = parcels.ParticleSet(fieldsetBJ, pclass=parcels.Particle, lon=X, lat=Y, time=0)\n", - "\n", - "output = psetAA.ParticleFile(name=\"bickleyjetAA.zarr\", outputdt=timedelta(hours=1))\n", - "\n", - "psetAA.execute(\n", - " parcels.kernels.AdvectionAnalytical + psetAA.Kernel(ZonalBC),\n", - " dt=timedelta(hours=1), # needs to be the same as outputdt for Analytical Advection\n", - " runtime=timedelta(days=1),\n", - " output_file=output,\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And then show the particle trajectories in an animation\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%capture\n", - "\n", - "ds = xr.open_zarr(\"bickleyjetAA.zarr\")\n", - "\n", - "fig = plt.figure(figsize=(7, 5), constrained_layout=True)\n", - "ax = fig.add_subplot()\n", - "\n", - "ax.set_ylabel(\"Meridional distance [m]\")\n", - "ax.set_xlabel(\"Zonal distance [m]\")\n", - "ax.set_xlim(0, 2e4)\n", - "ax.set_ylim(-2500, 2500)\n", - "\n", - "timerange = np.unique(ds[\"time\"].values[np.isfinite(ds[\"time\"])])\n", - "\n", - "# Indices of the data where time = 0\n", - "time_id = np.where(ds[\"time\"] == timerange[0])\n", - "\n", - "sc = ax.scatter(ds[\"lon\"].values[time_id], ds[\"lat\"].values[time_id], c=\"b\")\n", - "\n", - "t = str(timerange[0].astype(\"timedelta64[h]\"))\n", - "title = ax.set_title(f\"Particles at t = {t}\")\n", - "\n", - "\n", - "def animate(i):\n", - " t = str(timerange[i].astype(\"timedelta64[h]\"))\n", - " title.set_text(f\"Particles at t = {t}\")\n", - "\n", - " time_id = np.where(ds[\"time\"] == timerange[i])\n", - " sc.set_offsets(np.c_[ds[\"lon\"].values[time_id], ds[\"lat\"].values[time_id]])\n", - "\n", - "\n", - "anim = FuncAnimation(fig, animate, frames=len(timerange), interval=100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "HTML(anim.to_jshtml())" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Like with the double gyre above, we can also compute these trajectories with the `AdvectionRK4` kernel\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "psetRK4 = parcels.ParticleSet(fieldsetBJ, pclass=parcels.Particle, lon=X, lat=Y)\n", - "\n", - "psetRK4.execute(\n", - " [parcels.kernels.AdvectionRK4, ZonalBC],\n", - " dt=timedelta(minutes=5),\n", - " runtime=timedelta(days=1),\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And finally, we can again compare the end locations from the `AdvectionRK4` and `AdvectionAnalytical` simulations\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.plot(psetRK4.lon, psetRK4.lat, \"r.\", label=\"RK4\")\n", - "plt.plot(psetAA.lon, psetAA.lat, \"b.\", label=\"Analytical\")\n", - "plt.legend()\n", - "plt.show()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "parcels-dev", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/tutorial_particle_field_interaction.ipynb b/docs/user_guide/examples_v3/tutorial_particle_field_interaction.ipynb deleted file mode 100644 index 333ad40b8e..0000000000 --- a/docs/user_guide/examples_v3/tutorial_particle_field_interaction.ipynb +++ /dev/null @@ -1,488 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# Particle-Field interaction\n", - "\n", - "This notebook illustrates a simple way to make particles interact with a ` Field` object and modify it. The ` Field` will thus change at each step of the simulation, and will be written using the same time resolution as the particle outputs, in as many `netCDF` files.\n", - "\n", - "The concept is similar to that of [Field sampling](https://docs.oceanparcels.org/en/latest/examples/tutorial_sampling.html): here instead, on top of reading the field value at their location, particles are able to alter it as defined in the `Kernel`. To do this, it is important to keep in mind that:\n", - "\n", - "- Particles have to be defined as `Particles`\n", - "- `Field` writing at each `outputdt` is not default and has to be enabled\n", - "- The time of the `Field` to be saved has to be updated within a `Kernel`\n", - "\n", - "In this example, particles will carry a tracer and release it into a clean `Field` during their advection by surface currents. To show how can particles interact with a `Field` and alter it, the exchange of such tracer is modelled here with a discretized version of the mass transfer equation, defined as follows:\n", - "\n", - "\\begin{equation}\n", - "\\Delta c*{particle}(t) = aC*{field}(t-1) - bc\\_{particle}(t-1)\n", - "\\end{equation}\n", - "\n", - "In Eq.1, $c_{particle}$ is the tracer concentration associated with the particle, $C_{field}$ is the tracer concentration in seawater at particle location, and $a$ and $b$ are weights that modulate the sorption of tracer from seawater and its desorption, respectively.\n", - "\n", - "Additionally to a relevant `Kernel`, we will define a suitable particle class to store $c_{particle}$, as it is needed to solve Eq.1.\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Particle altering a Field during advection\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from datetime import timedelta\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import netCDF4\n", - "import numpy as np\n", - "import xarray as xr\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "In this specific example, particles will be advected by surface ocean velocities stored in netCDF files in the folder `GlobCurrent_example_data`. We will store these in a `FieldSet` object, and then add a `Field` to it to represent the tracer field. This latter field will be initialized with zeroes, as we assume that this tracer is absent on the ocean surface and released by particles only. Note that, in order to conserve mass, it is important to set `interp_method='nearest'` for the tracer Field.\n", - "\n", - "As we are interested in storing this new field during the simulation, we will set its `.to_write` method to `True`. Note that this only works for Fields that consist of only _one_ snapshot in time.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# Velocity fields\n", - "example_dataset_folder = parcels.download_example_dataset(\"GlobCurrent_example_data\")\n", - "fname = f\"{example_dataset_folder}/*.nc\"\n", - "filenames = {\"U\": fname, \"V\": fname}\n", - "variables = {\n", - " \"U\": \"eastward_eulerian_current_velocity\",\n", - " \"V\": \"northward_eulerian_current_velocity\",\n", - "}\n", - "dimensions = {\n", - " \"U\": {\"lat\": \"lat\", \"lon\": \"lon\", \"time\": \"time\"},\n", - " \"V\": {\"lat\": \"lat\", \"lon\": \"lon\", \"time\": \"time\"},\n", - "}\n", - "fieldset = parcels.FieldSet.from_netcdf(filenames, variables, dimensions)\n", - "\n", - "# In order to assign the same grid to the tracer field,\n", - "# it is convenient to load a single velocity file\n", - "fname1 = f\"{example_dataset_folder}/20030101000000-GLOBCURRENT-L4-CUReul_hs-ALT_SUM-v02.0-fv01.0.nc\"\n", - "filenames1 = {\"U\": fname1, \"V\": fname1}\n", - "\n", - "# a field with the same variables and dimensions\n", - "# as the other velocity fields\n", - "field_for_size = parcels.FieldSet.from_netcdf(filenames1, variables, dimensions)\n", - "\n", - "# Adding the tracer field to the FieldSet\n", - "# with same dimensions as the velocity fields\n", - "dimsC = [len(field_for_size.U.lat), len(field_for_size.U.lon)]\n", - "dataC = np.zeros([dimsC[0], dimsC[1]])\n", - "\n", - "# the new Field will be called C, for tracer Concentration.\n", - "# For mass conservation, interp_method='nearest'\n", - "fieldC = parcels.Field(\"C\", dataC, grid=field_for_size.U.grid, interp_method=\"nearest\")\n", - "\n", - "# add C field to the velocity FieldSet\n", - "fieldset.add_field(fieldC)\n", - "\n", - "# enable the writing of Field C during execution\n", - "fieldset.C.to_write = True" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "Some global parameters have to be defined, such as $a$ and $b$ of Eq.1, and a weight that works as a conversion factor from $\\Delta c_{particle}$ to $C_{field}$.\n", - "We will add these parameters to the `FieldSet`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "fieldset.add_constant(\"a\", 10)\n", - "fieldset.add_constant(\"b\", 0.2)\n", - "fieldset.add_constant(\"weight\", 0.01)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "We will now define a new particle class. A `VectorParticle` is a `Particle` having a `Variable` to store the current tracer concentration `c` associated with it. As in this case we want our particles to release a tracer into a clean field, we will initialize `c` with an arbitrary value of `100`.\n", - "\n", - "We also need to define the `Kernel` that performs the particle-field interaction. In this Kernel, we will implement Eq.1, so that $\\Delta c_{particle}$ can be used to update $c_{particle}$ and $C_{field}$ at the particle location, and thus get their values at the current time $t$.\n", - "\n", - "Additionally, the time of `fieldset.C` is updated within the `Interaction` Kernel, which is an important step to properly write its value.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "VectorParticle = parcels.Particle.add_variable(\"c\", dtype=np.float32, initial=100.0)\n", - "\n", - "\n", - "def Interaction(particle, fieldset, time):\n", - " \"\"\"define the interaction between the particle and the fieldset.C field.\n", - " the exchange is obtained as a discretized mass transfer equation,\n", - " with a and b as the rate constants\"\"\"\n", - " deltaC = fieldset.a * fieldset.C[particle] - fieldset.b * particle.c\n", - "\n", - " ei = fieldset.C.unravel_index(particle.ei)\n", - " xi, yi = ei[2], ei[1]\n", - "\n", - " if abs(particle.lon - fieldset.C.grid.lon[xi + 1]) < abs(\n", - " particle.lon - fieldset.C.grid.lon[xi]\n", - " ):\n", - " xi += 1\n", - " if abs(particle.lat - fieldset.C.grid.lat[yi + 1]) < abs(\n", - " particle.lat - fieldset.C.grid.lat[yi]\n", - " ):\n", - " yi += 1\n", - "\n", - " particle.c += deltaC\n", - "\n", - " # weight, defined as a constant for the FieldSet, acts here as a conversion factor between c_particle and C_field\n", - " fieldset.C.data[0, yi, xi] += -deltaC * fieldset.weight\n", - "\n", - " # update Field C time\n", - " fieldset.C.grid.time[0] = time\n", - "\n", - "\n", - "def WriteInitial(particle, fieldset, time):\n", - " \"\"\"store the initial conditions of fieldset.C\"\"\"\n", - " fieldset.C.grid.time[0] = time\n", - "\n", - "\n", - "# for simplicity, we'll track a single particle here\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset, pclass=VectorParticle, lon=[24.5], lat=[-34.8]\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "Three things are worth noticing in the code above:\n", - "\n", - "- The use of `fieldset.C[particle]`\n", - "- The computation of the relevant grid cell (`xi`, `yi`)\n", - "- Writing $C_{field}$ through `fieldset.C.data[0, yi, xi]`\n", - "\n", - "Because `fieldset.C[particle]` interpolates the $C_{field}$ value from the nearest grid cell to the particle, it is important to also write to that same grid cell. That is not completely trivial to do in Parcels, which is why lines 7-11 in the cell above calculate which `xi` and `yi` are closest to the particle longitude and latitude (this extends trivially to depth too). Our first guess is `particle.xi[fieldset.C.igrid]`, the location in the particular grid, but it could be that `xi+1` is closer, which is what the `if`-statements are for.\n", - "\n", - "The new indices are then used in `fieldset.C.data[0, yi, xi]` to access the field value in the cell where the particle is found, that can be different from the result of the interpolation at the particle's coordinates. Note that here we need to use `fieldset.C.data[0, yi, xi]` both for calculating `deltaC` and for the consequent update of the cell value for consistency between the forcing (the seawater-particle gradient) and its effect (the exchange, and consequent alteration of the field).\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "Now we are going to execute the advection of the particle and the simultaneous release of the tracer it carries. We will thus add the `interactionKernel` defined above to the built-in Kernel `AdvectionRK4`.\n", - "\n", - "Before running the advection, we will execute the `pset` with the `WriteInitial` for `dt=0`: this will write the initial condition of fieldset.C to a `netCDF` file.\n", - "\n", - "While particle outputs will be written in a file named `interaction.zarr` at every `outputdt`, the field will be automatically written in `netCDF` files named `interaction_wxyzC.nc`, with `wxyz` being the number of the output and `C` the `FieldSet` variable of our interest. Note that you can use tools like [ncrcat](https://linux.die.net/man/1/ncrcat) (on linux/macOS) to combine these separate files into one large `netCDF` file after the simulation.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "output_file = pset.ParticleFile(name=r\"interaction.zarr\", outputdt=timedelta(days=1))\n", - "\n", - "pset.execute(\n", - " # the particle will FIRST be transported by currents\n", - " # and THEN interact with the field\n", - " parcels.kernels.AdvectionRK4 + pset.Kernel(Interaction),\n", - " dt=timedelta(days=1),\n", - " # we are going to track the particle and save\n", - " # its trajectory and tracer concentration for 24 days\n", - " runtime=timedelta(days=25),\n", - " output_file=output_file,\n", - ")\n", - "\n", - "# # Also write the last location of the particle to the output file\n", - "# output_file.write_latest_locations(pset, time=pset[0].time)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "12", - "metadata": {}, - "source": [ - "We can see that $c_{particle}$ has been saved along with particle trajectory, as expected.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "pset_traj = xr.open_zarr(r\"interaction.zarr\")\n", - "\n", - "print(pset_traj[\"c\"].values)\n", - "\n", - "plt.plot(pset_traj[\"lon\"].T, pset_traj[\"lat\"].T, \".-\")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "But what about `fieldset.C`? We can see that it has been accordingly modified during particle motion. Using `fieldset.C` we can access the field as resulting at the end of the run, with no information about the previous time steps.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# Copy the final field data in a new array\n", - "c_results = fieldset.C.data[0, :, :].copy()\n", - "\n", - "# using a mask for fieldset.C.data on land\n", - "c_results[[field_for_size.U.data == 0][0][0]] = np.nan\n", - "\n", - "# masking the field where its value is zero:\n", - "# areas that have not been modified by the particle,\n", - "# for clearer plotting\n", - "c_results[c_results == 0] = np.nan\n", - "\n", - "try: # Works if Cartopy is installed\n", - " import cartopy\n", - " import cartopy.crs as ccrs\n", - "\n", - " extent = [10, 33, -37, -29]\n", - "\n", - " X = fieldset.U.lon\n", - " Y = fieldset.U.lat\n", - "\n", - " plt.figure(figsize=(12, 6))\n", - " ax = plt.axes(projection=ccrs.Mercator())\n", - " ax.set_extent(extent)\n", - "\n", - " ax.add_feature(cartopy.feature.OCEAN, facecolor=\"lightgrey\")\n", - " ax.add_feature(cartopy.feature.LAND, edgecolor=\"black\", facecolor=\"floralwhite\")\n", - " gl = ax.gridlines(\n", - " xlocs=np.linspace(10, 34, 13), ylocs=np.linspace(-29, -37, 9), draw_labels=True\n", - " )\n", - " gl.right_labels = False\n", - " gl.bottom_labels = False\n", - "\n", - " xx, yy = np.meshgrid(X, Y)\n", - "\n", - " results = ax.pcolormesh(\n", - " xx,\n", - " yy,\n", - " (c_results),\n", - " transform=ccrs.PlateCarree(),\n", - " vmin=0,\n", - " )\n", - " cbar = plt.colorbar(mappable=results, ax=ax)\n", - " cbar.ax.text(0.8, 0.070, \"$C_{field}$ concentration\", rotation=270, fontsize=12)\n", - "\n", - "except:\n", - " print(\"Please install the Cartopy package.\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "When looking at tracer concentrations, we see that $c_{particle}$ decreases along its trajectory (right to left), as it is releasing the tracer it carries. Accordingly, values of $C_{field}$ provided by particle interaction progressively reduce along the particle's route.\n", - "\n", - "Notice that the first particle-field interaction occurs at time $t = 1$ day, and namely after the execution of the first step of `AdvectionRK4`, as shown by the unaltered field value at the particle's starting location.\n", - "In order to let the particle interact before being advected, we would have to change the order in which the two Kernels are added together in `pset.execute`, i.e. `pset.execute(interactionKernel + AdvectionRK4, ...)`. In this latter case, the interaction would not occur at the particle's final position instead.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "x_centers, y_centers = np.meshgrid(\n", - " fieldset.U.lon - np.diff(fieldset.U.lon[:2]) / 2,\n", - " fieldset.U.lat - np.diff(fieldset.U.lat[:2]) / 2,\n", - ")\n", - "\n", - "fig, ax = plt.subplots(1, 1, figsize=(10, 7), constrained_layout=True)\n", - "ax.set_facecolor(\"lightgrey\") # For visual coherence with the plot above\n", - "\n", - "fieldplot = ax.pcolormesh(\n", - " x_centers[-28:-17, 22:41],\n", - " y_centers[-28:-17, 22:41],\n", - " c_results[-28:-18, 22:40],\n", - " vmin=0,\n", - " vmax=0.2,\n", - " cmap=\"viridis\",\n", - ")\n", - "# Zoom on the area of interest\n", - "field_cbar = plt.colorbar(fieldplot, ax=ax)\n", - "field_cbar.ax.text(3, 0.070, \"$C_{field}$ concentration\", rotation=270, fontsize=12)\n", - "\n", - "particle = plt.scatter(\n", - " pset_traj[\"lon\"][:].values[0, :],\n", - " pset_traj[\"lat\"][:].values[0, :],\n", - " c=pset_traj[\"c\"][:].values[0, :],\n", - " vmin=0,\n", - " s=100,\n", - " edgecolor=\"white\",\n", - ")\n", - "particle_cbar = plt.colorbar(particle, ax=ax, location=\"top\")\n", - "particle_cbar.ax.text(42, 1.8, \"$c_{particle}$ concentration\", fontsize=12);" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "Finally, to see the `C` field in time we have to load the `.nc` files produced during the run. In the following plots, particle location and field values are shown at each time step.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "fig, ax = plt.subplots(5, 5, figsize=(30, 20))\n", - "\n", - "daycounter = 1\n", - "\n", - "for i in range(len(ax)):\n", - " for j in range(len(ax)):\n", - " data = netCDF4.Dataset(r\"interaction_00\" + f\"{daycounter:02}\" + \"C.nc\")\n", - "\n", - " # copying the final field data in a new array\n", - " c_results = data[\"C\"][0, 0, :, :].data.copy()\n", - "\n", - " # using a mask for fieldset.C.data on land\n", - " c_results[[field_for_size.U.data == 0][0][0]] = np.nan\n", - "\n", - " # masking the field where its value is zero:\n", - " # areas that have not been modified by the particle,\n", - " # for clearer plotting\n", - " c_results[c_results == 0] = np.nan\n", - "\n", - " # visual coherence with the plots above\n", - " ax[i, j].set_facecolor(\"lightgrey\")\n", - "\n", - " fieldplot = ax[i, j].pcolormesh(\n", - " x_centers[-28:-17, 22:41],\n", - " y_centers[-28:-17, 22:41],\n", - " c_results[-28:-18, 22:40],\n", - " vmin=0,\n", - " vmax=0.2,\n", - " cmap=\"viridis\",\n", - " )\n", - "\n", - " particle = ax[i, j].scatter(\n", - " pset_traj[\"lon\"][:].values[0, daycounter - 1],\n", - " pset_traj[\"lat\"][:].values[0, daycounter - 1],\n", - " c=pset_traj[\"c\"][:].values[0, daycounter - 1],\n", - " vmin=0,\n", - " vmax=100,\n", - " s=100,\n", - " edgecolor=\"white\",\n", - " )\n", - "\n", - " # plotting particle location at current time step:\n", - " # daycounter-1 due to different indexing\n", - " ax[i, j].set_title(\"Day \" + str(daycounter - 1))\n", - "\n", - " daycounter += 1 # next day\n", - "\n", - "fig.subplots_adjust(right=0.8)\n", - "fig.subplots_adjust(top=0.8)\n", - "\n", - "cbar_ax = fig.add_axes([0.82, 0.12, 0.03, 0.7])\n", - "fig.colorbar(fieldplot, cax=cbar_ax)\n", - "cbar_ax.tick_params(labelsize=18)\n", - "cbar_ax.text(3, 0.08, \"$C_{field}$ concentration\", fontsize=25, rotation=270)\n", - "\n", - "cbar_ax1 = fig.add_axes([0.1, 0.85, 0.7, 0.04])\n", - "fig.colorbar(particle, cax=cbar_ax1, orientation=\"horizontal\")\n", - "cbar_ax1.tick_params(labelsize=18)\n", - "cbar_ax1.text(42, 1.2, \"$c_{particle}$ concentration\", fontsize=25);" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "parcels", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.2" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/examples_v3/tutorial_splitparticles.ipynb b/docs/user_guide/examples_v3/tutorial_splitparticles.ipynb deleted file mode 100644 index d954fbd3be..0000000000 --- a/docs/user_guide/examples_v3/tutorial_splitparticles.ipynb +++ /dev/null @@ -1,161 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Adding new particles to a ParticleSet during runtime\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "There are use-cases, where it is important to be able to add particles 'on-the-fly', during the runtime of a Parcels simulation.\n", - "\n", - "Unfortuantely, Parcels does not (yet) support adding new particles _within_ a `Kernel`. A workaround is to temporarily leave the `execution()` call to add particles via the `ParticleSet.add()` method, before continuing with `execution()`.\n", - "\n", - "See the example below, where we add 'mass' to a particle each timestep, based on a probablistic condition, and then split the particle once its 'mass' is larger than 5\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from datetime import timedelta\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import xarray as xr\n", - "from IPython.display import HTML\n", - "from matplotlib.animation import FuncAnimation\n", - "\n", - "import parcels" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Load the CopernicusMarine data in the Agulhas region from the example_datasets\n", - "example_dataset_folder = parcels.download_example_dataset(\n", - " \"CopernicusMarine_data_for_Argo_tutorial\"\n", - ")\n", - "\n", - "ds_fields = xr.open_mfdataset(f\"{example_dataset_folder}/*.nc\", combine=\"by_coords\")\n", - "ds_fields.load() # load the dataset into memory\n", - "\n", - "# Convert to SGRID-compliant dataset and create FieldSet\n", - "fields = {\"U\": ds_fields[\"uo\"], \"V\": ds_fields[\"vo\"]}\n", - "ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)\n", - "fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "GrowingParticle = parcels.Particle.add_variable([parcels.Variable(\"mass\", initial=0)])\n", - "\n", - "\n", - "def GrowParticles(particles, fieldset):\n", - " # 25% chance per timestep for particle to grow\n", - " growing_particles = np.random.random_sample(len(particles)) < 0.25\n", - " particles[growing_particles].mass += 1.0\n", - "\n", - "\n", - "def SplitParticles(particles, fieldset):\n", - " splitting_particles = particles.mass >= 5.0\n", - " particles[splitting_particles].mass = particles[splitting_particles].mass / 2.0\n", - "\n", - "\n", - "pset = parcels.ParticleSet(\n", - " fieldset=fieldset,\n", - " pclass=GrowingParticle,\n", - " lon=0,\n", - " lat=0,\n", - " time=fieldset.time_interval.left,\n", - ")\n", - "outfile = parcels.ParticleFile(\"growingparticles.zarr\", outputdt=np.timedelta64(1, \"h\"))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pset.execute(\n", - " GrowParticles,\n", - " runtime=np.timedelta64(40, \"h\"),\n", - " dt=np.timedelta64(1, \"h\"),\n", - " output_file=outfile,\n", - " verbose_progress=False,\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The 'trick' is that we place the `pset.execute()` call in a for-loop, so that we leave the Kernel-loop and can add Particles to the ParticleSet.\n", - "\n", - "Indeed, if we plot the mass of particles as a function of time, we see that new particles are created every time a particle reaches a mass of 5.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds_out = xr.open_zarr(\"growingparticles.zarr\")\n", - "plt.plot(\n", - " (ds_out.time.values[:].T - ds_fields.time.values[0]).astype(\"timedelta64[h]\"),\n", - " ds_out.mass.T,\n", - ")\n", - "plt.grid()\n", - "plt.xlabel(\"Time [h]\")\n", - "plt.ylabel(\"Particle 'mass'\")\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "docs", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.2" - } - }, - "nbformat": 4, - "nbformat_minor": 1 -} diff --git a/docs/user_guide/examples_v3/tutorial_stommel_uxarray.ipynb b/docs/user_guide/examples_v3/tutorial_stommel_uxarray.ipynb deleted file mode 100644 index e987401d75..0000000000 --- a/docs/user_guide/examples_v3/tutorial_stommel_uxarray.ipynb +++ /dev/null @@ -1,387 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Stommel Gyre on Unstructured Grid\n", - "This tutorial walks a simple example of using Parcels for particle advection on an unstructured grid. The purpose of this tutorial is to introduce you to the new way fields and fieldsets can be instantiated in Parcels using UXArray DataArrays and UXArray grids.\n", - "\n", - "We focus on a simple example, using constant-in-time velocity and pressure fields for the classic barotropic Stommel Gyre. This example dataset is included in Parcels' new `parcels._datasets` module. This module provides example XArray and UXArray datasets that are compatible with Parcels and mimic the way many general circulation model outputs are represented in (U)XArray. " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Loading the example dataset\n", - "Creating a particle simulation starts with defining a dataset that contains the fields that will be used to influence particle attributes, such as position, through kernels. In this example, we focus on advection. Because of this, the dataset we're using will provide velocity fields for our simulation.\n", - "\n", - "Parcels now includes pre-canned example datasets to demonstrate the schema of XArray and UXArray datasets that are compatible with Parcels. For unstructured grid datasets, you can use the `parcels._datasets.unstructured.generic.datasets` dictionary to see which datasets are available for unstructured grids." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from parcels._datasets.unstructured.generic import datasets as datasets_unstructured" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "datasets_unstructured.keys()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In this example, we'll be using the stommel_gyre_delaunay example dataset. This dataset is created by generating a delaunay triangulation of a uniform grid of points in a square domain $x \\in [0,60^\\circ] \\times [0,60^\\circ]$. There is a single vertical layer that is 1000m thick. This layer is defined by the layer surfaces $z_f = 0$ and $z_f = 1000$." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds = datasets_unstructured[\"stommel_gyre_delaunay\"]\n", - "ds" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(\"lon\", ds.uxgrid.face_lon.min().data[()], ds.uxgrid.face_lon.max().data[()])\n", - "print(\"lat\", ds.uxgrid.face_lat.min().data[()], ds.uxgrid.face_lat.max().data[()])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In the dataset, we have the following dimensions\n", - "\n", - "* `time: 1` - The number of time levels that the variables in this dataset are defined at. \n", - "* `nz1: 1` - The number of vertical layers. The `nz1` dimension is associated with the `nz1` coordinate that defines the vertical position of the center of each vertical layer. The `nz1` coordinate consists of non-negative values that are assumed to increase with `nz1` dimension index.\n", - "* `n_face: 721` - The number of 2-d unstructured grid faces in the `UXArray.grid`\n", - "* `nz: 2` - The number of vertical layer interfaces. The `nz` dimension is associated with the `nz` coordinate that defines the vertical positions of the interfaces of each vertical layer. The `nz` coordinate consists of non-negative values that are assumed to increase with `nz` dimension index. Note that the number of layer interfaces is always the number of layers plus one.\n", - "* `n_node: 400` - The number of corner node vertices in the grid.\n", - "\n", - "Whenever you are building a UXArray dataset for use in Parcels, its important to keep in mind that these dimensions and coordinates are assumed to exist for your dataset. Further, it is highly recommended that you use UXArray when possible to load unstructured general circulation model data when possible. This ensures that other characteristics, such as the counterclockwise ordering of vertices for each element, are defined properly for use in Parcels." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Defining a Grid, Fields, and Vector Fields\n", - "\n", - "A `UXArray.Dataset` consists of multiple `UXArray.UxDataArray`'s and a `UXArray.UxGrid`. Parcels views general circulation model data through the `Field` and `VectorField` classes. A `Field` is defined by its `name`, `data`, `grid`, and `interp_method`. A `VectorField` can be constructed by using 2 or 3 `Field`'s. The `Field.data` attribute can be either an `XArray.DataArray` or `UXArray.UxDataArray` object. The `Field.grid` attribute is of type `Parcels.XGrid` or `Parcels.UXGrid`. Last, the `interp_method` is a dynamic function that can be set at runtime to define the interpolation procedure for the `Field`. This gives you the flexibility to use one of the pre-defined interpolation methods included with Parcels v4, or to create your own interpolator. \n", - "\n", - "The first step to creating a `Field` (or `VectorField`) is to define the Grid. For an unstructured grid, we will create a `Parcels.UXGrid` object, which requires a `UxArray.grid` and the vertical layer interface positions. Setting the `mesh` to `\"spherical\"` is a legacy feature from Parcels v3 that enables unit conversion from `m/s` to `deg/s`; this is needed in this case since the grid locations are defined in units of degrees." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from parcels.uxgrid import UxGrid\n", - "\n", - "grid = UxGrid(grid=ds.uxgrid, z=ds.coords[\"nz\"], mesh=\"spherical\")\n", - "# You can view the uxgrid object with the following command:\n", - "grid.uxgrid" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "With the `UxGrid` object defined, we can now define our `Field` objects, provided we can align a suitable interpolator what that `Field`. Aligning an interpolator requires you to be cognizant of the location that each `DataArray` is associated with. Since Parcels v4 provides flexibility to customize your interpolation scheme, care must be taken when pairing an interpolation scheme with a field. On unstructured grids, data is typically registered to \"nodes\", \"faces\", or \"edges\". For example, with FESOM2 data, `u` and `v` velocity components are face registered while the vertical velocity component `w` is node registered.\n", - "\n", - "In Parcels, grid searching is conducted with respect to the faces. In other words, when a grid index `ei` is provided to an interpolation method, this refers the face index `fi` at vertical layer `zi` (when unraveled). Within the interpolation method, the `field.grid.uxgrid.face_node_connectivity` attribute can be used to obtain the node indices that surround the face. Using these connectivity tables is necessary for properly indexing node registered data.\n", - "\n", - "For the example Stommel gyre dataset in this tutorial, the `u` and `v` velocity components are face registered (similar to FESOM). Parcels includes a nearest neighbor interpolator for face registered unstructured grid data through `Parcels.application_kernels.interpolation.UxConstantFaceConstantZC`. Below, we create the `Field`s `U` and `V` and associate them with the `UxGrid` we created previously and this interpolation method." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from parcels.application_kernels.interpolation import UxConstantFaceConstantZC\n", - "from parcels.field import Field\n", - "\n", - "U = Field(\n", - " name=\"U\",\n", - " data=ds.U,\n", - " grid=grid,\n", - " interp_method=UxConstantFaceConstantZC,\n", - ")\n", - "V = Field(\n", - " name=\"V\",\n", - " data=ds.V,\n", - " grid=grid,\n", - " interp_method=UxConstantFaceConstantZC,\n", - ")\n", - "P = Field(\n", - " name=\"P\",\n", - " data=ds.p,\n", - " grid=grid,\n", - " interp_method=UxConstantFaceConstantZC,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now that we've defined the `U` and `V` fields, we can define a `VectorField`. The `VectorField` is created in a similar manner, except that it is initialized with `Field` objects. You can optionally define an `interp_method` on the `VectorField`. When this is done, the `VectorField.interp_method` is used for interpolation; otherwise, evaluation of the `VectorField` is done component-wise using the `interp_method` associated with each component separately." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from parcels.field import VectorField\n", - "\n", - "UV = VectorField(name=\"UV\", U=U, V=V)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Defining the FieldSet\n", - "With all of the fields defined, that we want for this simulation, we can now create the `FieldSet`. As the name suggests, the `FieldSet` is the set of all `Field`s that will be used for a particle simulation. A `FieldSet` is initialized with a list of `Field` objects" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from parcels.fieldset import FieldSet\n", - "\n", - "fieldset = FieldSet([UV, UV.U, UV.V, P])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setting your own custom interpolator\n", - "You may be wondering how to set your own custom interpolator. In Parcels v4, this is as simple as defining a function that matches a specific API. The API you need to match is defined in the `field.py` module in the `Field._interp_template` and `VectorField._interp_template`. Specifically,\n", - "\n", - "```python\n", - "def _interp_template(\n", - " self, # Field or VectorField\n", - " ti: int, # Time index\n", - " ei: int, # Flat grid index\n", - " bcoords: np.ndarray, # Barycentric coordinates relative to the cell vertices\n", - " tau: np.float32 | np.float64, # Time interpolation weight\n", - " t: np.float32 | np.float64, # Current simulation time\n", - " z: np.float32 | np.float64, # Current particle depth\n", - " y: np.float32 | np.float64, # Current particle y-position\n", - " x: np.float32 | np.float64, # Current particle x-position\n", - " ) -> np.float32 | np.float64 # For `Field`, returns a float value.\n", - "```\n", - "\n", - "So long as your function matches this API, you can define such a function and set the `Field.interp_method` to that function.\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "\n", - "\n", - "def my_custom_interpolator(\n", - " self,\n", - " ti: int,\n", - " ei: int,\n", - " bcoords: np.ndarray,\n", - " tau: np.float32 | np.float64,\n", - " t: np.float32 | np.float64,\n", - " z: np.float32 | np.float64,\n", - " y: np.float32 | np.float64,\n", - " x: np.float32 | np.float64,\n", - ") -> np.float32 | np.float64:\n", - " \"\"\"Custom interpolation method for the P field.\n", - " This method interpolates the value at a face by averaging the values of its neighboring faces.\n", - " While this may be nonsense, it demonstrates how to create a custom interpolation method.\"\"\"\n", - "\n", - " zi, fi = self.grid.unravel_index(ei)\n", - " neighbors = self.grid.uxgrid.face_face_connectivity[fi]\n", - " f_at_neighbors = self.data.values[ti, zi, neighbors]\n", - " # Interpolate using the average of the neighboring face values\n", - " if len(f_at_neighbors) > 0:\n", - " return np.mean(f_at_neighbors)\n", - " # If no neighbors, return the value at the face itself\n", - " else:\n", - " return self.data.values[ti, zi, fi]\n", - "\n", - "\n", - "# Assign the custom interpolator to the P field\n", - "P.interp_method = my_custom_interpolator" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Understanding the context inside an interpolator method\n", - "Providing the `Field` object as an input to an interpolator exposes you to a ton of useful information and methods for building complex interpolators. Particularly, the `Field.grid` attribute gives you access to connectivity tables and metric terms that you may find useful for constructing an interpolator. For context, the `Parcels.UXGrid` class is built on top of the `Parcels.BaseGrid` class (much likes it's structured grid `Parcels.XGrid` counterpart). The `Parcels.UXGrid` class combines a `UXArray.grid` object alongside the vertical layer interfaces, which provides sufficient information to define the API that the `BaseGrid` class demands. This includes\n", - "\n", - "* `search` - A method for returning a flat grid index `ei` for a position `(x,y,z)`\n", - "* `ravel_index` - A method for converting a face index `fi` and a vertical layer index `zi` into a single flat grid index `ei`\n", - "* `unravel_index` - A method for converted a single flat grid index `ei` into a face index `fi` and a vertical layer index `zi`\n", - "\n", - "The `ravel/unravel` methods are a necessity for most interpolators. For unstructured grids, the `Field.grid.uxgrid` attribute give you access to all of the attributes associated with a `UxArray.grid` object (See https://uxarray.readthedocs.io/en/latest/api.html#grid for more details.)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Running the forward integration" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from parcels import AdvectionEE, AdvectionRK4" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from datetime import datetime, timedelta\n", - "\n", - "from parcels import Particle, ParticleSet" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "num_particles = 2\n", - "\n", - "pset = ParticleSet(\n", - " fieldset,\n", - " lon=np.random.uniform(3.0, 57.0, size=(num_particles,)),\n", - " lat=np.random.uniform(3.0, 57.0, size=(num_particles,)),\n", - " depth=50.0 * np.ones(shape=(num_particles,)),\n", - " time=0.0\n", - " * np.ones(\n", - " shape=(num_particles,)\n", - " ), # important otherwise initialization appears to take forever?\n", - " pclass=Particle,\n", - ")\n", - "print(len(pset), \"particles created\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from tqdm import tqdm\n", - "\n", - "from parcels import FieldOutOfBoundError\n", - "\n", - "# for capturing positions\n", - "_lon = [pset.lon]\n", - "_lat = [pset.lat]\n", - "\n", - "# output / sub-experiment time stepping\n", - "output_dt = timedelta(minutes=10)\n", - "endtime = output_dt\n", - "\n", - "# run 40 x 6 hours and capture positions after each iteration\n", - "for num_output in tqdm(range(40)):\n", - " # if one particle errors, let's top all of them\n", - " try:\n", - " pset.execute(\n", - " endtime=endtime,\n", - " dt=timedelta(seconds=60),\n", - " kernels=AdvectionEE,\n", - " verbose_progress=False,\n", - " )\n", - " except FieldOutOfBoundError:\n", - " print(\"out of bounds, stopping (all particles)\")\n", - " break\n", - "\n", - " # on to the next sub experiment\n", - " endtime += output_dt\n", - " _lon.append(pset.lon)\n", - " _lat.append(pset.lat)\n", - "\n", - "# merge captured positions\n", - "lon = np.vstack(_lon)\n", - "lat = np.vstack(_lat)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from matplotlib import pyplot as plt\n", - "\n", - "plt.plot(lon, lat, \"-\");" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/examples_v3/tutorial_timevaryingdepthdimensions.ipynb b/docs/user_guide/examples_v3/tutorial_timevaryingdepthdimensions.ipynb deleted file mode 100644 index 05dab635c0..0000000000 --- a/docs/user_guide/examples_v3/tutorial_timevaryingdepthdimensions.ipynb +++ /dev/null @@ -1,163 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Time-varying depths\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Some hydrodynamic models (such as SWASH) have time-evolving depth dimensions, for example because they follow the waves on the free surface. Parcels can work with these types of models, but it is a bit involved to set up. That is why we explain here how to run Parcels on `FieldSets` with time-evolving depth dimensions\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from datetime import timedelta\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import xarray as xr\n", - "\n", - "import parcels" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Here, we use sample data from the SWASH model. We first set the `filenames` and `variables`\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "example_dataset_folder = parcels.download_example_dataset(\"SWASH_data\")\n", - "filenames = f\"{example_dataset_folder}/field_*.nc\"\n", - "variables = {\n", - " \"U\": \"cross-shore velocity\",\n", - " \"V\": \"along-shore velocity\",\n", - " \"depth_u\": \"time varying depth_u\",\n", - "}" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now, the first key step when reading time-evolving depth dimensions is that we specify `depth` as `'not_yet_set'` in the `dimensions` dictionary\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "dimensions = {\n", - " \"U\": {\"lon\": \"x\", \"lat\": \"y\", \"depth\": \"not_yet_set\", \"time\": \"t\"},\n", - " \"V\": {\"lon\": \"x\", \"lat\": \"y\", \"depth\": \"not_yet_set\", \"time\": \"t\"},\n", - " \"depth_u\": {\"lon\": \"x\", \"lat\": \"y\", \"depth\": \"not_yet_set\", \"time\": \"t\"},\n", - "}" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, _after_ we create the `FieldSet` object, we set the `depth` dimension of the relevant `Fields` to `fieldset.depth_u` and `fieldset.depth_w`, using the `Field.set_depth_from_field()` method\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fieldset = parcels.FieldSet.from_netcdf(\n", - " filenames, variables, dimensions, mesh=\"flat\", allow_time_extrapolation=True\n", - ")\n", - "fieldset.U.set_depth_from_field(fieldset.depth_u)\n", - "fieldset.V.set_depth_from_field(fieldset.depth_u)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now, we can create a ParticleSet, run those and plot them\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pset = parcels.ParticleSet(fieldset, parcels.Particle, lon=9.5, lat=12.5, depth=-0.1)\n", - "\n", - "pfile = pset.ParticleFile(\"SwashParticles\", outputdt=timedelta(seconds=0.05))\n", - "\n", - "pset.execute(\n", - " parcels.kernels.AdvectionRK4, dt=timedelta(seconds=0.005), output_file=pfile\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds = xr.open_zarr(\"SwashParticles.zarr\")\n", - "\n", - "plt.plot(ds.lon.T, ds.lat.T, \".-\")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that, even though we use 2-dimensional `AdvectionRK4`, the particle still moves down, because the grid itself moves down\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "parcels", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 06a578d792..ffab9d72eb 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -47,9 +47,6 @@ examples/tutorial_nestedgrids.ipynb examples/tutorial_manipulating_field_data.ipynb ``` - - - ## Create ParticleSets ```{toctree} @@ -81,11 +78,10 @@ examples/tutorial_write_in_kernel.ipynb examples/explanation_interpolation.md examples/tutorial_interpolation.ipynb +examples/tutorial_peninsula_AvsCgrid.ipynb ``` - - ## Run a simulation @@ -97,20 +93,6 @@ examples/tutorial_interpolation.ipynb examples/tutorial_dt_integrators.ipynb ``` - - - - - - - - - - ## Example Kernels ```{toctree} @@ -123,10 +105,12 @@ examples/tutorial_diffusion.ipynb examples/tutorial_interaction.ipynb ``` - - ```{toctree} :hidden: :caption: Other v3 to v4 migration guide +examples/tutorial_stuck_particles.ipynb +examples/tutorial_unstuck_Agrid.ipynb +examples/tutorial_homepage_animation.md + ``` diff --git a/src/parcels/_datasets/remote.py b/src/parcels/_datasets/remote.py index 5fc93868a8..3b8d83280f 100644 --- a/src/parcels/_datasets/remote.py +++ b/src/parcels/_datasets/remote.py @@ -50,6 +50,7 @@ def _get_data_home() -> Path: "data/CopernicusMarine_data_for_Argo_tutorial/cmems_mod_glo_phy-so_anfc_0.083deg_P1D-m_so_31.00E-33.00E_33.00S-30.00S_0.49-2225.08m_2024-01-01-2024-02-01.nc", "data/CopernicusMarine_data_for_Argo_tutorial/cmems_mod_glo_phy-thetao_anfc_0.083deg_P1D-m_thetao_31.00E-33.00E_33.00S-30.00S_0.49-2225.08m_2024-01-01-2024-02-01.nc", ] + + ["data/CopernicusMarine_data_for_stuck_particles_tutorial/cmems_mod_glo_phy_my_0.083deg_P1D-m_NL.nc"] # + [ # "data/DecayingMovingEddy_data/decaying_moving_eddyU.nc", # "data/DecayingMovingEddy_data/decaying_moving_eddyV.nc", @@ -220,6 +221,7 @@ class _Purpose(enum.Enum): # ("Peninsula_data/T", (_V3Dataset(_ODIE,"data/Peninsula_data/peninsulaT.nc"), _Purpose.TUTORIAL)), # ("GlobCurrent_example_data/data", (_V3Dataset(_ODIE,"data/GlobCurrent_example_data/*000000-GLOBCURRENT-L4-CUReul_hs-ALT_SUM-v02.0-fv01.0.nc", pre_decode_cf_callable=patch_dataset_v4_compat), _Purpose.TUTORIAL)), ("CopernicusMarine_data_for_Argo_tutorial/data", (_V3Dataset(_ODIE,"data/CopernicusMarine_data_for_Argo_tutorial/cmems_mod_glo_phy-*.nc"), _Purpose.TUTORIAL)), + ("CopernicusMarine_data_for_stuck_particles_tutorial/data", (_V3Dataset(_ODIE,"data/CopernicusMarine_data_for_stuck_particles_tutorial/cmems_mod_glo_phy_my_0.083deg_P1D-m_NL.nc"), _Purpose.TUTORIAL)), # ("DecayingMovingEddy_data/U", (_V3Dataset(_ODIE,"data/DecayingMovingEddy_data/decaying_moving_eddyU.nc"), _Purpose.TUTORIAL)), # ("DecayingMovingEddy_data/V", (_V3Dataset(_ODIE,"data/DecayingMovingEddy_data/decaying_moving_eddyV.nc"), _Purpose.TUTORIAL)), ("FESOM_periodic_channel/fesom_channel", (_V3Dataset(_ODIE,"data/FESOM_periodic_channel/fesom_channel.nc"), _Purpose.TUTORIAL)), diff --git a/src/parcels/_datasets/structured/generated.py b/src/parcels/_datasets/structured/generated.py index 23b3f8299f..7eba61f559 100644 --- a/src/parcels/_datasets/structured/generated.py +++ b/src/parcels/_datasets/structured/generated.py @@ -230,8 +230,8 @@ def peninsula_dataset(xdim=100, ydim=50, mesh="flat", grid_type="A"): http://archimer.ifremer.fr/doc/00157/26792/24888.pdf """ domainsizeX, domainsizeY = (1.0e5, 5.0e4) - La = np.linspace(1e3, domainsizeX, xdim, dtype=np.float32) - Wa = np.linspace(1e3, domainsizeY, ydim, dtype=np.float32) + La = np.linspace(0, domainsizeX, xdim, dtype=np.float32) + Wa = np.linspace(0, domainsizeY, ydim, dtype=np.float32) u0 = 1 x0 = domainsizeX / 2 @@ -253,15 +253,15 @@ def peninsula_dataset(xdim=100, ydim=50, mesh="flat", grid_type="A"): V[:, :] = -2 * u0 * R**2 * ((x - x0) * y) / (((x - x0) ** 2 + y**2) ** 2) U[landpoints] = 0.0 V[landpoints] = 0.0 - Udims = ["YG", "XG"] - Vdims = ["YG", "XG"] + Udims = ["YC", "XC"] + Vdims = ["YC", "XC"] elif grid_type == "C": U = np.zeros(P.shape) V = np.zeros(P.shape) - V[:, 1:] = (P[:, 1:] - P[:, :-1]) / (La[1] - La[0]) U[1:, :] = -(P[1:, :] - P[:-1, :]) / (Wa[1] - Wa[0]) - Udims = ["YC", "XG"] - Vdims = ["YG", "XC"] + V[:, 1:] = (P[:, 1:] - P[:, :-1]) / (La[1] - La[0]) + Udims = ["YG", "XC"] + Vdims = ["YC", "XG"] else: raise ValueError(f"Grid_type {grid_type} is not a valid option") @@ -273,15 +273,15 @@ def peninsula_dataset(xdim=100, ydim=50, mesh="flat", grid_type="A"): { "U": (Udims, U), "V": (Vdims, V), - "P": (["YG", "XG"], P), + "P": (["YC", "XC"], P), }, coords={ - "YC": (["YC"], np.arange(ydim) + 0.5, {"axis": "Y"}), - "YG": (["YG"], np.arange(ydim), {"axis": "Y", "c_grid_axis_shift": -0.5}), - "XC": (["XC"], np.arange(xdim) + 0.5, {"axis": "X"}), - "XG": (["XG"], np.arange(xdim), {"axis": "X", "c_grid_axis_shift": -0.5}), - "lat": (["YG"], lat, {"axis": "Y", "c_grid_axis_shift": 0.5}), - "lon": (["XG"], lon, {"axis": "X", "c_grid_axis_shift": -0.5}), + "YC": (["YC"], np.arange(ydim) - 0.5, {"axis": "Y", "c_grid_axis_shift": +0.5}), + "YG": (["YG"], np.arange(ydim), {"axis": "Y"}), + "XC": (["XC"], np.arange(xdim) - 0.5, {"axis": "X", "c_grid_axis_shift": +0.5}), + "XG": (["XG"], np.arange(xdim), {"axis": "X"}), + "lat": (["YG"], lat, {"axis": "Y"}), + "lon": (["XG"], lon, {"axis": "X"}), }, ).pipe( sgrid._attach_sgrid_metadata, @@ -292,7 +292,7 @@ def peninsula_dataset(xdim=100, ydim=50, mesh="flat", grid_type="A"): node_coordinates=("lon", "lat"), face_dimensions=( sgrid.FaceNodePadding("XC", "XG", sgrid.Padding.LOW), - sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.HIGH), + sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.LOW), ), ), ) @@ -333,23 +333,23 @@ def stommel_gyre_dataset(xdim=200, ydim=200, grid_type="A"): U[j, i] = -(1 - math.exp(-xi / es) - xi) * math.pi**2 * np.cos(math.pi * yi) * scalefac V[j, i] = (math.exp(-xi / es) / es - 1) * math.pi * np.sin(math.pi * yi) * scalefac if grid_type == "C": - V[:, 1:] = (P[:, 1:] - P[:, 0:-1]) / dx * a U[1:, :] = -(P[1:, :] - P[0:-1, :]) / dy * b - Udims = ["YC", "XG"] - Vdims = ["YG", "XC"] + V[:, 1:] = (P[:, 1:] - P[:, 0:-1]) / dx * a + Udims = ["YG", "XC"] + Vdims = ["YC", "XG"] else: - Udims = ["YG", "XG"] - Vdims = ["YG", "XG"] + Udims = ["YC", "XC"] + Vdims = ["YC", "XC"] return xr.Dataset( {"U": (Udims, U), "V": (Vdims, V), "P": (["YG", "XG"], P)}, coords={ - "YC": (["YC"], np.arange(ydim) + 0.5, {"axis": "Y"}), - "YG": (["YG"], np.arange(ydim), {"axis": "Y", "c_grid_axis_shift": -0.5}), - "XC": (["XC"], np.arange(xdim) + 0.5, {"axis": "X"}), - "XG": (["XG"], np.arange(xdim), {"axis": "X", "c_grid_axis_shift": -0.5}), - "lat": (["YG"], lat, {"axis": "Y", "c_grid_axis_shift": 0.5}), - "lon": (["XG"], lon, {"axis": "X", "c_grid_axis_shift": -0.5}), + "YC": (["YC"], np.arange(ydim) - 0.5, {"axis": "Y", "c_grid_axis_shift": +0.5}), + "YG": (["YG"], np.arange(ydim), {"axis": "Y"}), + "XC": (["XC"], np.arange(xdim) - 0.5, {"axis": "X", "c_grid_axis_shift": +0.5}), + "XG": (["XG"], np.arange(xdim), {"axis": "X"}), + "lat": (["YG"], lat, {"axis": "Y"}), + "lon": (["XG"], lon, {"axis": "X"}), }, ).pipe( sgrid._attach_sgrid_metadata, @@ -360,7 +360,7 @@ def stommel_gyre_dataset(xdim=200, ydim=200, grid_type="A"): node_coordinates=("lon", "lat"), face_dimensions=( sgrid.FaceNodePadding("XC", "XG", sgrid.Padding.LOW), - sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.HIGH), + sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.LOW), ), ), ) diff --git a/src/parcels/interpolators/_xinterpolators.py b/src/parcels/interpolators/_xinterpolators.py index d89ed9cff2..54606c4b04 100644 --- a/src/parcels/interpolators/_xinterpolators.py +++ b/src/parcels/interpolators/_xinterpolators.py @@ -425,84 +425,64 @@ def is_land(ti: int, zi: int, yi: int, xi: int): vval = corner_dataV[ti, zi, yi, xi, :] return np.where(np.isclose(uval, 0.0) & np.isclose(vval, 0.0), True, False) + if is_dask_collection(u): + u = u.compute() + v = v.compute() + if vectorfield.W: + w = w.compute() + f_u = np.ones_like(xsi) + if lenZ == 1: + land = is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & (eta > 0) + else: + land = is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & is_land(0, 1, 0, 0) & is_land(0, 1, 0, 1) & (eta > 0) + f_u[land] = f_u[land] * (a + b * eta[land]) / eta[land] + + if lenZ == 1: + land = is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & (eta < 1) + else: + land = is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & is_land(0, 1, 1, 0) & is_land(0, 1, 1, 1) & (eta < 1) + f_u[land] = f_u[land] * (1 - b * eta[land]) / (1 - eta[land]) + + u = u * f_u + if vectorfield.grid._mesh.is_spherical(): + u /= 1852 * 60 * np.cos(np.deg2rad(particle_positions["y"])) + f_v = np.ones_like(eta) + if lenZ == 1: + land = is_land(0, 0, 0, 0) & is_land(0, 0, 1, 0) & (xsi > 0) + else: + land = is_land(0, 0, 0, 0) & is_land(0, 0, 1, 0) & is_land(0, 1, 0, 0) & is_land(0, 1, 1, 0) & (xsi > 0) + f_v[land] = f_v[land] * (a + b * xsi[land]) / xsi[land] if lenZ == 1: - f_u = np.where(is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & (eta > 0), f_u * (a + b * eta) / eta, f_u) - f_u = np.where(is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & (eta < 1), f_u * (1 - b * eta) / (1 - eta), f_u) - f_v = np.where(is_land(0, 0, 0, 0) & is_land(0, 0, 1, 0) & (xsi > 0), f_v * (a + b * xsi) / xsi, f_v) - f_v = np.where(is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & (xsi < 1), f_v * (1 - b * xsi) / (1 - xsi), f_v) + land = is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & (xsi < 1) else: - f_u = np.where( - is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & is_land(0, 1, 0, 0) & is_land(0, 1, 0, 1) & (eta > 0), - f_u * (a + b * eta) / eta, - f_u, - ) - f_u = np.where( - is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & is_land(0, 1, 1, 0) & is_land(0, 1, 1, 1) & (eta < 1), - f_u * (1 - b * eta) / (1 - eta), - f_u, - ) - f_v = np.where( - is_land(0, 0, 0, 0) & is_land(0, 0, 1, 0) & is_land(0, 1, 0, 0) & is_land(0, 1, 1, 0) & (xsi > 0), - f_v * (a + b * xsi) / xsi, - f_v, - ) - f_v = np.where( - is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & is_land(0, 1, 0, 1) & is_land(0, 1, 1, 1) & (xsi < 1), - f_v * (1 - b * xsi) / (1 - xsi), - f_v, - ) - f_u = np.where( - is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & (zeta > 0), - f_u * (a + b * zeta) / zeta, - f_u, - ) - f_u = np.where( - is_land(0, 1, 0, 0) & is_land(0, 1, 0, 1) & is_land(0, 1, 1, 0) & is_land(0, 1, 1, 1) & (zeta < 1), - f_u * (1 - b * zeta) / (1 - zeta), - f_u, - ) - f_v = np.where( - is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & (zeta > 0), - f_v * (a + b * zeta) / zeta, - f_v, - ) - f_v = np.where( - is_land(0, 1, 0, 0) & is_land(0, 1, 0, 1) & is_land(0, 1, 1, 0) & is_land(0, 1, 1, 1) & (zeta < 1), - f_v * (1 - b * zeta) / (1 - zeta), - f_v, - ) + land = is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & is_land(0, 1, 0, 1) & is_land(0, 1, 1, 1) & (xsi < 1) + f_v[land] = f_v[land] * (1 - b * xsi[land]) / (1 - xsi[land]) + + v = v * f_v + if vectorfield.grid._mesh.is_spherical(): + v /= 1852 * 60 - u *= f_u - v *= f_v if vectorfield.W: f_w = np.ones_like(zeta) - f_w = np.where( - is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & is_land(0, 1, 0, 0) & is_land(0, 1, 0, 1) & (eta > 0), - f_w * (a + b * eta) / eta, - f_w, - ) - f_w = np.where( - is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & is_land(0, 1, 1, 0) & is_land(0, 1, 1, 1) & (eta < 1), - f_w * (a - b * eta) / (1 - eta), - f_w, - ) - f_w = np.where( - is_land(0, 0, 0, 0) & is_land(0, 0, 1, 0) & is_land(0, 1, 0, 0) & is_land(0, 1, 1, 0) & (xsi > 0), - f_w * (a + b * xsi) / xsi, - f_w, - ) - f_w = np.where( - is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & is_land(0, 1, 0, 1) & is_land(0, 1, 1, 1) & (xsi < 1), - f_w * (a - b * xsi) / (1 - xsi), - f_w, - ) + land = is_land(0, 0, 0, 0) & is_land(0, 0, 0, 1) & is_land(0, 1, 0, 0) & is_land(0, 1, 0, 1) & (eta > 0) + f_w[land] = f_w[land] * (a + b * eta[land]) / eta[land] + + land = is_land(0, 0, 1, 0) & is_land(0, 0, 1, 1) & is_land(0, 1, 1, 0) & is_land(0, 1, 1, 1) & (eta < 1) + f_w[land] = f_w[land] * (1 - b * eta[land]) / (1 - eta[land]) - w *= f_w + land = is_land(0, 0, 0, 0) & is_land(0, 0, 1, 0) & is_land(0, 1, 0, 0) & is_land(0, 1, 1, 0) & (xsi > 0) + f_w[land] = f_w[land] * (a + b * xsi[land]) / xsi[land] + + land = is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & is_land(0, 1, 0, 1) & is_land(0, 1, 1, 1) & (xsi < 1) + f_w[land] = f_w[land] * (1 - b * xsi[land]) / (1 - xsi[land]) + + w = w * f_w else: w = np.zeros_like(u) + return u, v, w @@ -648,7 +628,7 @@ def interp( values[some_land] = val[some_land] / w_sum[some_land] # If a particle hits exactly one of the 8 corner points, extract it - exact_mask = dist2 == 0 & valid_mask + exact_mask = (dist2 == 0) & valid_mask exact_vals = np.sum(np.where(exact_mask, corner_data, 0.0), axis=(0, 1, 2, 3)) has_exact = np.any(exact_mask, axis=(0, 1, 2, 3)) diff --git a/tests/test_advection.py b/tests/test_advection.py index 8e44301bf2..0a033f2131 100644 --- a/tests/test_advection.py +++ b/tests/test_advection.py @@ -386,11 +386,11 @@ def UpdateP(particles, fieldset): # pragma: no cover "kernel, rtol", [ (AdvectionRK2, 2e-2), - (AdvectionRK4, 5e-3), + (AdvectionRK4, 1e-2), (AdvectionRK45, 1e-3), ], ) -@pytest.mark.parametrize("grid_type", ["A"]) # TODO also implement C-grid once available +@pytest.mark.parametrize("grid_type", ["A", "C"]) def test_peninsula_fieldset(kernel, rtol, grid_type): npart = 2 ds = peninsula_dataset(grid_type=grid_type) diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py index a97e2cd879..ba78da6790 100644 --- a/tests/test_interpolation.py +++ b/tests/test_interpolation.py @@ -13,6 +13,7 @@ VectorField, ) from parcels._core.index_search import _search_time_index +from parcels._core.mesh import get_mesh from parcels._datasets.structured.generated import simple_UV_dataset from parcels.interpolators import ( XFreeslip, @@ -113,26 +114,28 @@ def test_raw_2d_interpolation(field, interpolator, t, z, y, x, expected): np.testing.assert_equal(value, expected) +@pytest.mark.parametrize("mesh", ["flat", "spherical"]) @pytest.mark.parametrize( "func, t, z, y, x, expected", [ - (XPartialslip(), 1, 0, 0, 0.0, [[1], [1]]), - (XFreeslip(), 1, 0, 0.5, 1.5, [[1], [0.5]]), + (XPartialslip(), 1, 0, 0, 0.0, [[1.0], [1.0]]), + (XFreeslip(), 1, 0, 0.5, 1.5, [[1.0], [0.5]]), (XPartialslip(), 1, 0, 2.5, 1.5, [[0.75], [0.5]]), - (XFreeslip(), 1, 0, 2.5, 1.5, [[1], [0.5]]), + (XFreeslip(), 1, 0, 2.5, 1.5, [[1.0], [0.5]]), (XPartialslip(), 1, 0, 1.5, 0.5, [[0.5], [0.75]]), - (XFreeslip(), 1, 0, 1.5, 0.5, [[0.5], [1]]), + (XFreeslip(), 1, 0, 1.5, 0.5, [[0.5], [1.0]]), ( XFreeslip(), [1, 0], [0, 2], [1.5, 1.5], [2.5, 0.5], - [[0.5, 0.5], [1, 1]], + [[0.5, 0.5], [1.0, 1.0]], ), ], ) -def test_spatial_slip_interpolation(field, func, t, z, y, x, expected): +def test_spatial_slip_interpolation(field, func, t, z, y, x, expected, mesh): + field.grid._mesh = get_mesh(mesh) field.data[:] = 1.0 field.data[:, :, 1:3, 1:3] = 0.0 # Set zero land value to test spatial slip U = field @@ -140,6 +143,10 @@ def test_spatial_slip_interpolation(field, func, t, z, y, x, expected): UV = VectorField("UV", U, V, interp_method=func) velocities = UV[t, z, y, x] + expected = np.array(expected) + if mesh == "spherical": + expected[0] = expected[0] / (1852 * 60.0 * np.cos(np.radians(y))) + expected[1] = expected[1] / (1852 * 60.0) np.testing.assert_array_almost_equal(velocities, expected)