diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccddf66fef..622d835409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,8 @@ jobs: persist-credentials: false - uses: Parcels-code/pixi-lock/create-and-cache@38495788b79a5ff26009aecc15daa9a8310b8832 # v0.1.0 id: pixi-lock + with: + pixi-version: v0.70.2 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pixi-lock diff --git a/.readthedocs.yaml b/.readthedocs.yaml index eac4916df9..af33c564d2 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,8 +7,8 @@ build: jobs: create_environment: - asdf plugin add pixi - - asdf install pixi latest - - asdf global pixi latest + - asdf install pixi 0.70.2 + - asdf global pixi 0.70.2 install: - pixi install -e docs build: diff --git a/docs/_static/homepage.gif b/docs/_static/homepage.gif index 2210d6ae7d..4689dd893f 100644 Binary files a/docs/_static/homepage.gif and b/docs/_static/homepage.gif differ diff --git a/docs/user_guide/examples/explanation_grids.md b/docs/user_guide/examples/explanation_grids.md index 61484bdbf0..0c54eaaba8 100644 --- a/docs/user_guide/examples/explanation_grids.md +++ b/docs/user_guide/examples/explanation_grids.md @@ -1,10 +1,190 @@ # 📖 Grids -Parcels `Field` objects exist on a (structured) `parcels.XGrid` or (unstructured) `parcels.Uxgrid`. Here we describe these grids on a conceptual level. +In Lagrangian ocean analysis, virtual particle tracking requires the accurate interpolation of physical properties (flow velocities and tracer properties) to particle locations. The underlying data that forces the particle movement will likely be defined on a discretised grid. Parcels can natively handle two styles of grids; structured and unstructured, where parcels `Field` objects exist on a (structured) `parcels.XGrid` and conform to [SGRID](https://sgrid.github.io/sgrid/) conventions, or on a (unstructured) `parcels.Uxgrid` and conform to [UGRID](https://ugrid-conventions.github.io/ugrid-conventions/) conventions. Here we describe these grids on a conceptual level. -```{note} -The contents for this page are still under development in v4. -TODO -- link to xgcm.Grid documentation -- adapt from v3 grid indexing tutorial (../examples_v3/documentation_indexing.ipynb) +Under the hood, every `Field` in a `FieldSet` has a `grid` attribute. This `grid` stores the spatial and temporal information of the Field coordinates. The number of Grids in a FieldSet is thus always smaller or equal to the number of Field objects; and this is what the "grid number" column in `FieldSet.describe()` refers to. + +## Structured grids + +A structured grid is composed of quadrilateral elements, that are indexed using logical 2D or 3D indices, like $(i,j,k)$. In `xarray` terminology, these indices correspond to `dimensions`. For example, in NEMO datasets, $(x,y)$ typically define these dimensions horizontally, where the physical coordinates are $(glamf, gphif)$. A major benefit of structured grids is that grid cell neighbours are easily found by decrementing or incrementing these dimensions. However, in parcels we either perform a binary search in the case of 1-dimensional coordinates, or use a hash table in the case of 2/3-dimensional coordinates. + +There are two styles of structured grids, rectilinear and curvilinear, as shown in Figure 1. + +1. Rectilinear grids are typically aligned with the coordinate axes (that is, there is a one-to-one mapping between the dimensions and the physical coordinate space), making them simple and computationally efficient to query. However, they're limited by the fact that it is difficult to resolve complex coastlines without high resolution, and they suffer from singularities in the flow fields at the poles where the grid lines converge. + +2. Curvilinear grids allow for curved grid lines, which allow for better representation of coastlines. They often move the poles onto land to avoid singularities occuring in the ocean. However, they become trickier to work with, as the spatial positions of their vertices (their ["coordinates" in xarray-parlance](https://docs.xarray.dev/en/latest/user-guide/terminology.html#term-Coordinate)) are stored in separate 2D arrays, and moving to an eastward neighbour is not as simple as incrementing the $i$-th dimension by 1. + +![Figure 1 - Grid discretizations handled by Parcels. In the horizontal plane; (a) rectilinear, (b) curvilinear. In the vertical plane; (c) z-levels, (d) sigma-levels. Adapted from [Parcels v2.0 paper](https://doi.org/10.5194/gmd-12-3571-2019)](image.png) + +## Unstructured grids + +TODO: For Joe. + +## How your data may be defined + +Regardless of your grid type, how your data is defined on your grid is equally important. Here, we will describe how you can interpret your data at a very general level, and the concept applies for any $n$-gon ($n$-sided polygon with $n \ge 3$). + +### Nodes, edges, and faces + +Let's assume we have a simple 2D rectilinear grid. The grid is composed of a number of nodes (or vertices), connected by edges, which together construct grid cells and grid cell faces. In Figure 2, we draw a simple grid cell. Here, your data may be defined on the corners of the grid cell (the nodes/vertices), at the centre of the cell face, or across a cell edge. + +![Figure 2 - A simple grid cell. Blue circles denote nodes (or vertices) of the grid cell. The red circle denotes the centre of the cell face.](image-1.png) + +You will need to make several assumptions about your data. Your data may represent a point-wise "sample" of some field. For example, velocity data may be defined at the nodes of your grid, and a typical assumption to make is that you can bi-linearly interpolate these data points to your particle positions. In such an example, your velocity field may look like Figure 3. Bi-linear interpolation ensures continuity of the velocity at the cell boundaries, however, it does not ensure a smooth transition. + +
+Code to generate Figure 3 +```python +import numpy as np +import matplotlib.pyplot as plt +from scipy.interpolate import interp2d + +xx, yy = np.meshgrid(np.linspace(0,1,2), np.linspace(0,1,2), indexing='ij') +interp_xx, interp_yy = np.linspace(0,1,51), np.linspace(0,1,51) +u_data = np.array([[0, -0.5], [1, -1]]) +v_data = np.array([[0, -2], [0, -2]]) + +interp_u = interp2d(xx.flatten(), yy.flatten(), u_data.flatten(), kind='linear') +interp_u_data = interp_u(np.linspace(0,1,51), np.linspace(0,1,51)) + +interp_v = interp2d(xx.flatten(), yy.flatten(), v_data.flatten(), kind='linear') +interp_v_data = interp_v(np.linspace(0,1,51), np.linspace(0,1,51)) + +interp_speed_data = np.sqrt(interp_u_data**2 + interp_v_data**2) + +mm=5 +plt.axhline(0, linewidth=0.5, color='k') +plt.axvline(0, linewidth=0.5, color='k') +plt.axhline(1, linewidth=0.5, color='k') +plt.axvline(1, linewidth=0.5, color='k') +cb = plt.pcolormesh(interp_xx, interp_yy, interp_speed_data, shading='gouraud', cmap=plt.cm.viridis) +plt.quiver(interp_xx[::mm], interp_yy[::mm], interp_u_data[::mm,::mm], interp_v_data[::mm,::mm]) +plt.title("Bi-linear interpolation of velocity data at nodes") + +plt.colorbar(cb, ax=plt.gca(), label='Speed [m/s]') +plt.xlabel('X [km]') +plt.ylabel('Y [km]') + +plt.xlim([-0.2,1.2]) +plt.ylim([-0.2,1.2]) + +```` + +
+ +![Figure 3 - Bi-linear interpolation of point-wise data at nodes](image-2.png) + +Alternatively, your data may represent an "average value" across a cell face. For example, your temperature and salinity data may be defined at the cell centre, and represent an average value for the entire grid cell. A typical assumption to make is that you can nearest-neighbour interpolate these data points to your particle positions. In such a case, your temperature field may look like figure 4. A nearest-neighbour interpolation scheme ensures you have a piece-wise constant field, typically with sharp transitions at grid cell boundaries. + +
+Code to generate Figure 4 +```python +import numpy as np +import matplotlib.pyplot as plt + +xx, yy = np.meshgrid(np.linspace(0,1,2), np.linspace(0,1,2), indexing='ij') +data = np.array([[-1, 1], [0, 2]]) +mm=5 +for i in [1,-1]: + for j in [-1, 1]: + plt.pcolormesh(xx + i*0.5, yy + j*0.5, 18+np.random.rand(1)*data, shading='auto', cmap=plt.cm.viridis) + plt.scatter(xx+ i*0.5, yy + j*0.5, c='k', zorder=20) +cb = plt.pcolormesh(xx+0.5, yy + 0.5, 18 + 0.5*data, shading='auto', cmap=plt.cm.viridis, zorder=10) + +plt.title(f"Nearest-neighbour interpolation of\ntemperature data at cell centres") + +plt.colorbar(cb, ax=plt.gca(), label='Temperature [deg C]') +plt.xlabel('X [km]') +plt.ylabel('Y [km]') +plt.axhline(-1, linewidth=0.5, color='k', zorder=30) +plt.axhline(0, linewidth=0.5, color='k', zorder=30) +plt.axhline(1, linewidth=0.5, color='k', zorder=30) +plt.axhline(2, linewidth=0.5, color='k', zorder=30) +plt.axvline(-1, linewidth=0.5, color='k', zorder=30) +plt.axvline(0, linewidth=0.5, color='k', zorder=30) +plt.axvline(1, linewidth=0.5, color='k', zorder=30) +plt.axvline(2, linewidth=0.5, color='k', zorder=30) + +plt.xlim([-1,2]) +plt.ylim([-1,2]) +plt.show() +```` + +
+ +![Figure 4 - Nearest-neighbour interpolation of "grid cell averaged" data at cell centres](image-4.png) + +Your data may represent a value across a cell edge. For example, in (2D) Arakawa C-grid datasets, velocities are defined across an edge as they represent a "flux" across that cell edge. Additionally, these cell edges may not be aligned with the coordinate axes, and rather represent a velocity in the $i$ or $j$ direction. For structured grids, [Blanke and Raynaud]() proposed in 1997 to perform a 1D linear interpolation of the $i$ velocity in the $i$ direction, and similarly a 1D linear interpolation of the $j$ velocity in the $j$ direction. These velocities must then be rotated into meridional and zonal velocities, which parcels handles under the hood. In such a case, your velocity field may look like figure 5. + +This (uni)linear velocity interpolation is now often referred to as the Analytical interpolation scheme, and is what the [Ariane](https://ariane-code.cnrs.fr) and [TRACMASS](https://www.tracmass.org/index.html) Lagrangian codes also use. In Parcels, the time-stepping version of this interpolation is provided in the `CGrid_Velocity` Interpolator function. + +
+Code to generate Figure 5 +```python +import numpy as np +import matplotlib.pyplot as plt +from scipy.interpolate import interp1d + +interp_xx, interp_yy = np.linspace(0,1,51), np.linspace(0,1,51) +XX,YY = np.meshgrid(interp_xx, interp_yy, indexing='xy') +u_data = np.array([[2, 1]]) +v_data = np.array([[0, 1]]) + +interp_u = interp1d([0,1], u_data.flatten(), kind='linear') +interp_v = interp1d([0,1], v_data.flatten(), kind='linear') + +interp_u_data = interp_u(XX) +interp_v_data = interp_v(YY) +interp_speed_data = np.sqrt(interp_u_data**2 + interp_v_data**2) + +mm=5 +plt.axhline(0, linewidth=0.5, color='k') +plt.axvline(0, linewidth=0.5, color='k') +plt.axhline(1, linewidth=0.5, color='k') +plt.axvline(1, linewidth=0.5, color='k') +cb = plt.pcolormesh(interp_xx, interp_yy, interp_speed_data, vmin=0.75, vmax=2) +plt.quiver(interp_xx[::mm], interp_yy[::mm], interp_u_data[::mm,::mm], interp_v_data[::mm,::mm]) +plt.title(f"C-grid interpolation of velocity data\nacross cell edges") +plt.quiver(interp_xx[0], interp_yy[len(interp_yy)//2], +interp_u_data[len(interp_u_data)//2, 0], +0, +color='r', +scale=30) +plt.quiver(interp_xx[-1], interp_yy[len(interp_yy)//2], +interp_u_data[len(interp_u_data)//2, -1], +0, +color='r', +scale=30) + +plt.quiver(interp_xx[len(interp_yy)//2], interp_yy[0], +0, +interp_v_data[0, len(interp_v_data)//2], +color='b') +plt.quiver(interp_xx[len(interp_yy)//2], interp_yy[-1], +0, +interp_v_data[-1, len(interp_v_data)//2], +color='b') + +plt.colorbar(cb, ax=plt.gca(), label='Speed [m/s]') +plt.xlim([-0.2,1.2]) +plt.ylim([-0.2,1.2]) +plt.xlabel('X [km]') +plt.ylabel('Y [km]') +plt.show() + +``` +
+ +![Figure 5 - C-grid 1D interpolation of velocity data defined across a cell edge](image-5.png) + + +### Vertical coordinates + +Lastly, a short note on vertical coordinates. Parcels can handle two styles of vertical coordinates; z-levels which define fixed depth levels in physical space, and sigma-levels which define varying depth levels in physical space as a function of the water column depth. As sigma-levels are effectively "terrain-following", the grid cell faces may no longer be orthogonal to the domain surface. Figure 1 visualises these differences, and parcels handles this all under the hood. See the CROCO Tutorial for more details on the sigma-levels implementation. + + +### TODO: Add note on convert functions +- support for many common ocean model outputs -> converts xarray datasets into SGRID/UGRID compliant datasets. +- if your model is not currently supported, you can write your own, or reach out by raising a discussion thread. +- your grid determines the type of interpolater you will use. ``` diff --git a/docs/user_guide/examples/explanation_kernelloop.md b/docs/user_guide/examples/explanation_kernelloop.md index a726ac5979..cfde897244 100644 --- a/docs/user_guide/examples/explanation_kernelloop.md +++ b/docs/user_guide/examples/explanation_kernelloop.md @@ -59,14 +59,14 @@ import parcels.tutorial ds_fields = parcels.tutorial.open_dataset("CopernicusMarine_data_for_Argo_tutorial/data") # Create an idealised wind field and add it to the dataset -ydim, xdim = len(ds_fields.latitude), len(ds_fields.longitude) +tdim, ydim, xdim = (len(ds_fields.time),len(ds_fields.latitude), len(ds_fields.longitude)) ds_fields["UWind"] = xr.DataArray( - data=0.5 * np.ones((ydim, xdim)) * np.sin(ds_fields.latitude.values - ds_fields.latitude.values.mean())[:, None], - coords=[ds_fields.latitude, ds_fields.longitude]) + data=0.5 * np.ones((tdim, ydim, xdim)) * np.sin(ds_fields.latitude.values - ds_fields.latitude.values.mean())[None, :, None], + coords=[ds_fields.time, ds_fields.latitude, ds_fields.longitude]) ds_fields["VWind"] = xr.DataArray( - data=np.zeros((ydim, xdim)), - coords=[ds_fields.latitude, ds_fields.longitude]) + data=np.zeros((tdim, ydim, xdim)), + coords=[ds_fields.time, ds_fields.latitude, ds_fields.longitude]) fields = { "U": ds_fields["uo"], diff --git a/docs/user_guide/examples/image-1.png b/docs/user_guide/examples/image-1.png new file mode 100644 index 0000000000..bb13431723 Binary files /dev/null and b/docs/user_guide/examples/image-1.png differ diff --git a/docs/user_guide/examples/image-2.png b/docs/user_guide/examples/image-2.png new file mode 100644 index 0000000000..ad028a8cc2 Binary files /dev/null and b/docs/user_guide/examples/image-2.png differ diff --git a/docs/user_guide/examples/image-3.png b/docs/user_guide/examples/image-3.png new file mode 100644 index 0000000000..5430f553cc Binary files /dev/null and b/docs/user_guide/examples/image-3.png differ diff --git a/docs/user_guide/examples/image-4.png b/docs/user_guide/examples/image-4.png new file mode 100644 index 0000000000..1badc13049 Binary files /dev/null and b/docs/user_guide/examples/image-4.png differ diff --git a/docs/user_guide/examples/image-5.png b/docs/user_guide/examples/image-5.png new file mode 100644 index 0000000000..a37fc60a51 Binary files /dev/null and b/docs/user_guide/examples/image-5.png differ diff --git a/docs/user_guide/examples/image.png b/docs/user_guide/examples/image.png new file mode 100644 index 0000000000..b7458302de Binary files /dev/null and b/docs/user_guide/examples/image.png differ diff --git a/docs/user_guide/examples/tutorial_homepage_animation.md b/docs/user_guide/examples/tutorial_homepage_animation.md deleted file mode 100644 index 82f838f136..0000000000 --- a/docs/user_guide/examples/tutorial_homepage_animation.md +++ /dev/null @@ -1,150 +0,0 @@ -# 🎓 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/tutorial_interpolation.ipynb b/docs/user_guide/examples/tutorial_interpolation.ipynb index c5e76825c6..7132742528 100644 --- a/docs/user_guide/examples/tutorial_interpolation.ipynb +++ b/docs/user_guide/examples/tutorial_interpolation.ipynb @@ -5,7 +5,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# 🖥️ Using the built-in Interpolators\n", + "# 🖥️ Using the built-in `parcels.interpolators`\n", "Parcels comes with a number of different interpolation methods for fields on structured (`X`) and unstructured (`Ux`) grids. Here, we will look at a few common {py:obj}`parcels.interpolators` for tracer fields, and how to configure them in an idealised example. For more guidance on the sampling of such fields, check out the [sampling tutorial](./tutorial_sampling)." ] }, diff --git a/docs/user_guide/examples/tutorial_nemo.ipynb b/docs/user_guide/examples/tutorial_nemo.ipynb index 3620de50e5..2bf094c103 100644 --- a/docs/user_guide/examples/tutorial_nemo.ipynb +++ b/docs/user_guide/examples/tutorial_nemo.ipynb @@ -155,7 +155,7 @@ ")\n", "\n", "pset.execute(\n", - " parcels.kernels.AdvectionRK2,\n", + " [parcels.kernels.AdvectionEE],\n", " runtime=runtime,\n", " dt=np.timedelta64(1, \"D\"),\n", " output_file=pfile,\n", @@ -203,7 +203,7 @@ "metadata": {}, "outputs": [], "source": [ - "# handling periodic boundary conditions in post processing\n", + "# post processing\n", "df = df.with_columns((pl.col(\"x\") % 360).alias(\"x\"))\n", "df = df.with_columns(\n", " pl.when(pl.col(\"x\") <= 180)\n", @@ -223,24 +223,11 @@ }, "outputs": [], "source": [ - "# with a custom Kernel\n", - "def AdvectionRK2_periodicBC(particles, fieldset): # pragma: no cover\n", - " \"\"\"Advection of particles using second-order Runge-Kutta integration,\n", - " keeping particles between -180 and 180 degrees longitude.\n", - " \"\"\"\n", - " (u1, v1) = fieldset.UV[particles]\n", - " x1 = particles.x + u1 * 0.5 * particles.dt\n", - " y1 = particles.y + v1 * 0.5 * particles.dt\n", - " x1 = ((x1 + 180) % 360) - 180 # keep x1 within [-180, 180]\n", - "\n", - " (u2, v2) = fieldset.UV[\n", - " particles.t + 0.5 * particles.dt, particles.z, y1, x1, particles\n", - " ]\n", - " particles.dx += u2 * particles.dt\n", - " particles.dy += v2 * particles.dt\n", - "\n", - " # update particles.dx so particles.x + particles.dx stays within [-180, 180]\n", - " particles.dx = ((particles.x + particles.dx + 180) % 360) - 180 - particles.x\n", + "# with a Kernel\n", + "def periodicBC(particles, fieldset): # pragma: no cover\n", + " particles.dx = np.where(\n", + " particles.x + particles.dx > 180, particles.dx - 360, particles.dx\n", + " )\n", "\n", "\n", "pset = parcels.ParticleSet(fieldset, x=lonp, y=latp)\n", @@ -249,7 +236,7 @@ ")\n", "\n", "pset.execute(\n", - " AdvectionRK2_periodicBC,\n", + " [parcels.kernels.AdvectionEE, periodicBC],\n", " runtime=runtime,\n", " dt=np.timedelta64(1, \"D\"),\n", " output_file=pfile,\n", diff --git a/docs/user_guide/examples/tutorial_sampling.ipynb b/docs/user_guide/examples/tutorial_sampling.ipynb index 98336db2e9..70752e5e86 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` ([tutorial_unstuck_Agrid](tutorial_unstuck_Agrid.ipynb))\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` ([documentation_unstuck_Agrid](https://docs.oceanparcels.org/en/latest/examples/documentation_unstuck_Agrid.html#3.-Slip-boundary-conditions))\n" ] }, { diff --git a/docs/user_guide/examples/tutorial_stuck_particles.ipynb b/docs/user_guide/examples/tutorial_stuck_particles.ipynb deleted file mode 100644 index 28e6f75f06..0000000000 --- a/docs/user_guide/examples/tutorial_stuck_particles.ipynb +++ /dev/null @@ -1,931 +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](#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 deleted file mode 100644 index 89b8cefcc2..0000000000 --- a/docs/user_guide/examples/tutorial_unstuck_Agrid.ipynb +++ /dev/null @@ -1,1141 +0,0 @@ -{ - "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 new file mode 100644 index 0000000000..3180e8c6d4 --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_LargeRunsOutput.ipynb @@ -0,0 +1,300 @@ +{ + "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 new file mode 100644 index 0000000000..622297f4fe --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_advanced_zarr.ipynb @@ -0,0 +1,458 @@ +{ + "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 new file mode 100644 index 0000000000..9840e99bf3 --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_geospatial.ipynb @@ -0,0 +1,719 @@ +{ + "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 new file mode 100644 index 0000000000..7a1fd98343 --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_homepage_animation.ipynb @@ -0,0 +1,191 @@ +{ + "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 new file mode 100644 index 0000000000..af60f5b20e --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_indexing.ipynb @@ -0,0 +1,277 @@ +{ + "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 new file mode 100644 index 0000000000..880835f96b --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_stuck_particles.ipynb @@ -0,0 +1,1322 @@ +{ + "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 new file mode 100644 index 0000000000..5fa518bf5d --- /dev/null +++ b/docs/user_guide/examples_v3/documentation_unstuck_Agrid.ipynb @@ -0,0 +1,1618 @@ +{ + "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 new file mode 100644 index 0000000000..7d53b6fd89 --- /dev/null +++ b/docs/user_guide/examples_v3/example_brownian.py @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000000..01e6c401d4 --- /dev/null +++ b/docs/user_guide/examples_v3/example_decaying_moving_eddy.py @@ -0,0 +1,109 @@ +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 new file mode 100755 index 0000000000..622adea810 --- /dev/null +++ b/docs/user_guide/examples_v3/example_globcurrent.py @@ -0,0 +1,275 @@ +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 new file mode 100644 index 0000000000..56d8e2bebd --- /dev/null +++ b/docs/user_guide/examples_v3/example_mitgcm.py @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000000..348e21b328 --- /dev/null +++ b/docs/user_guide/examples_v3/example_moving_eddies.py @@ -0,0 +1,364 @@ +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 new file mode 100644 index 0000000000..d39d798a78 --- /dev/null +++ b/docs/user_guide/examples_v3/example_nemo_curvilinear.py @@ -0,0 +1,120 @@ +"""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 new file mode 100644 index 0000000000..4b68011d35 --- /dev/null +++ b/docs/user_guide/examples_v3/example_ofam.py @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000000..cccfdd7ce7 --- /dev/null +++ b/docs/user_guide/examples_v3/example_peninsula.py @@ -0,0 +1,321 @@ +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 new file mode 100644 index 0000000000..b433c270e4 --- /dev/null +++ b/docs/user_guide/examples_v3/example_radial_rotation.py @@ -0,0 +1,95 @@ +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 new file mode 100755 index 0000000000..068ac5812a --- /dev/null +++ b/docs/user_guide/examples_v3/example_stommel.py @@ -0,0 +1,248 @@ +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 new file mode 100644 index 0000000000..cbf14aa229 --- /dev/null +++ b/docs/user_guide/examples_v3/tutorial_analyticaladvection.ipynb @@ -0,0 +1,635 @@ +{ + "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 new file mode 100644 index 0000000000..333ad40b8e --- /dev/null +++ b/docs/user_guide/examples_v3/tutorial_particle_field_interaction.ipynb @@ -0,0 +1,488 @@ +{ + "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/tutorial_peninsula_AvsCgrid.ipynb b/docs/user_guide/examples_v3/tutorial_peninsula_AvsCgrid.ipynb similarity index 63% rename from docs/user_guide/examples/tutorial_peninsula_AvsCgrid.ipynb rename to docs/user_guide/examples_v3/tutorial_peninsula_AvsCgrid.ipynb index b1691e2f66..73c3a57599 100644 --- a/docs/user_guide/examples/tutorial_peninsula_AvsCgrid.ipynb +++ b/docs/user_guide/examples_v3/tutorial_peninsula_AvsCgrid.ipynb @@ -4,16 +4,23 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# 📖 The sensitivity of grid and velocity interpolation" + "# The impact of grid and velocity interpolation scheme on trajectories" ] }, { "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 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." + "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." ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "code", "execution_count": null, @@ -26,6 +33,7 @@ "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" @@ -44,7 +52,38 @@ "metadata": {}, "outputs": [], "source": [ - "from parcels._datasets.structured.generated import peninsula_dataset" + "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" ] }, { @@ -60,23 +99,23 @@ "metadata": {}, "outputs": [], "source": [ - "def SampleP(particles, fieldset):\n", - " particles.p = fieldset.P[particles]\n", + "def SampleP(particle, fieldset, time):\n", + " particle.p = fieldset.P[time, particle.lon, particle.lat, particle.depth]\n", "\n", "\n", - "def DeleteParticle(particles, fieldset):\n", - " any_error = particles.state >= 50 # This captures all Errors\n", - " particles[any_error].state = parcels.StatusCode.Delete\n", + "def DeleteParticle(particle, fieldset, time):\n", + " if particle.state >= 50:\n", + " particle.delete()\n", "\n", "\n", - "pclass = parcels.Particle.add_variable(parcels.Variable(\"p\", np.float32))" + "pclass = parcels.Particle.add_variables({\"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)) # TODO also add [\"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) and [\"analytical\"](https://docs.oceanparcels.org/en/latest/examples/tutorial_analyticaladvection.html))." ] }, { @@ -90,9 +129,7 @@ " \"\"\"Utility class to consolidate experiment parameters.\"\"\"\n", "\n", " grid_type: Literal[\"A\", \"C\"]\n", - " interp_method: Literal[\n", - " \"linear\", \"freeslip\", \"cgrid_velocity\"\n", - " ] # , \"analytical\"] TODO add analtical when implemented\n", + " interp_method: Literal[\"linear\", \"freeslip\", \"cgrid_velocity\", \"analytical\"]\n", "\n", " @property\n", " def fieldset_interp_method(self):\n", @@ -109,44 +146,43 @@ "\n", " @property\n", " def file_name(self):\n", - " return f\"Trajs_{self.grid_type}_{self.interp_method}.parquet\"\n", + " return f\"Trajs_{self.grid_type}_{self.interp_method}.zarr\"\n", "\n", " @property\n", " def plot_title(self):\n", - " if str(self.interp_method) == \"analytical\":\n", + " if self.interp_method == \"analytical\":\n", " interp_name = \"analytical advection\"\n", " else:\n", - " interp_name = f\"{str(self.interp_method).replace('_Velocity', '').replace('(...)', '')} interpolation\"\n", + " interp_name = f\"{self.interp_method.replace('_velocity', '')} interpolation\"\n", " return f\"{self.grid_type}grid & {interp_name}\"\n", "\n", "\n", "exps = [\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", + " 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", "]\n", "\n", - "dt = np.timedelta64(1000, \"s\") # setting output and execution timestep to same value\n", + "dt = 1e3 # setting output and execution timestep to same value\n", "\n", "for exp in exps:\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", + " fieldset = peninsula_fieldset(\n", + " grid_type=exp.grid_type, interp_method=exp.fieldset_interp_method\n", + " )\n", "\n", - " pset = parcels.ParticleSet(\n", - " fieldset, pclass=pclass, x=1e3 * np.ones(7), y=np.linspace(1e3, 10e3, 7)\n", + " pset = parcels.ParticleSet.from_line(\n", + " fieldset, pclass=pclass, size=7, start=(1e3, 1e3), finish=(1e3, 10e3)\n", " )\n", - " outfile = parcels.ParticleFile(exp.file_name, outputdt=dt, mode=\"w\")\n", + " outfile = pset.ParticleFile(name=exp.file_name, outputdt=dt)\n", "\n", " pset.execute(\n", " [exp.advection_kernel, SampleP, DeleteParticle],\n", - " runtime=np.timedelta64(100_000, \"s\"),\n", + " endtime=1e5,\n", " dt=dt,\n", " output_file=outfile,\n", - " verbose_progress=False,\n", " )" ] }, @@ -163,15 +199,16 @@ "metadata": {}, "outputs": [], "source": [ - "ds = peninsula_dataset(xdim=14, ydim=16, grid_type=\"C\")\n", - "landmask = np.ma.masked_values(ds.U.data[:, :], 0)\n", + "landmask = np.ma.masked_values(fieldset.U.data[0, :, :], 0)\n", "landmask = landmask.mask.astype(\"int\")\n", "\n", - "lonplot, latplot = np.meshgrid(ds.lon, ds.lat)\n", + "lonplot, latplot = np.meshgrid(fieldset.U.lon, fieldset.U.lat)\n", "\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", + "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", "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)" @@ -194,7 +231,7 @@ "\n", "\n", "def plot_experiment(ax, exp: Experiment) -> None:\n", - " df = parcels.read_particlefile(exp.file_name)\n", + " ds = xr.open_zarr(exp.file_name)\n", " ax.pcolormesh(lonplot, latplot, lmask.mask, cmap=\"Blues\")\n", " ax.scatter(\n", " lonplot,\n", @@ -206,12 +243,11 @@ " vmax=0.05,\n", " edgecolors=\"k\",\n", " )\n", - " for traj in df.partition_by(\"particle_id\", maintain_order=True):\n", - " ax.plot(traj[\"x\"], traj[\"y\"], linewidth=2)\n", + " ax.plot(ds[\"lon\"].T, ds[\"lat\"].T, linewidth=2)\n", " ax.set_title(exp.plot_title)\n", "\n", " # Set the same limits for all subplots\n", - " ax.set_xlim([lonplot.min(), lonplot.max()])\n", + " ax.set_xlim([fieldset.U.lon.min(), fieldset.U.lon.max()])\n", " ax.set_ylim([0, 23e3])\n", " m2km = lambda x, _: f\"{x / 1000:.1f}\"\n", " ax.xaxis.set_major_formatter(m2km)\n", @@ -219,7 +255,7 @@ " ax.set_xlabel(\"x [km]\")\n", "\n", "\n", - "for i, (ax, exp) in enumerate(zip(axs, exps, strict=True)):\n", + "for i, (ax, exp) in enumerate(zip(axs, exps)):\n", " if i == 0:\n", " ax.set_ylabel(\"y [km]\")\n", " plot_experiment(ax, exp)\n", @@ -253,25 +289,29 @@ " vmax=0.05,\n", " edgecolors=\"k\",\n", ")\n", - "ax.contour(lonplot, latplot, ds.P.data, colors=\"k\", levels=50)\n", + "ax.contour(lonplot, latplot, fieldset.P.data[0, :, :], colors=\"k\", levels=50)\n", "\n", "exps_of_interest = filter(\n", " lambda exp: (\n", " exp.plot_title\n", - " in [\"Agrid & XLinear interpolation\", \"Cgrid & CGrid interpolation\"]\n", + " in [\"Agrid & linear interpolation\", \"Cgrid & cgrid interpolation\"]\n", " ),\n", " exps,\n", ")\n", "handles = []\n", "for i, exp in enumerate(exps_of_interest):\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", + " 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", " # 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([lonplot.min(), lonplot.max()])\n", + "ax.set_xlim([fieldset.U.lon.min(), fieldset.U.lon.max()])\n", "ax.set_ylim([0, 23e3])\n", "ax.set_ylabel(\"y [km]\")\n", "ax.set_xlabel(\"x [km]\")\n", @@ -299,19 +339,16 @@ "fig, ax = plt.subplots(1, 1, figsize=(6, 4))\n", "\n", "for i, exp in enumerate(exps):\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", + " 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", "ax.hlines(0, 3, 11, linestyles=\"--\", color=\"k\")\n", "ax.set_xlabel(\"y-location at start [km]\")\n", "ax.set_xlim([3, 11])\n", @@ -330,7 +367,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Parcels:docs (3.14.6)", + "display_name": "parcels", "language": "python", "name": "python3" }, @@ -344,7 +381,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.6" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/docs/user_guide/examples_v3/tutorial_splitparticles.ipynb b/docs/user_guide/examples_v3/tutorial_splitparticles.ipynb new file mode 100644 index 0000000000..d954fbd3be --- /dev/null +++ b/docs/user_guide/examples_v3/tutorial_splitparticles.ipynb @@ -0,0 +1,161 @@ +{ + "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 new file mode 100644 index 0000000000..e987401d75 --- /dev/null +++ b/docs/user_guide/examples_v3/tutorial_stommel_uxarray.ipynb @@ -0,0 +1,387 @@ +{ + "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 new file mode 100644 index 0000000000..05dab635c0 --- /dev/null +++ b/docs/user_guide/examples_v3/tutorial_timevaryingdepthdimensions.ipynb @@ -0,0 +1,163 @@ +{ + "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 ffab9d72eb..06a578d792 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -47,6 +47,9 @@ examples/tutorial_nestedgrids.ipynb examples/tutorial_manipulating_field_data.ipynb ``` + + + ## Create ParticleSets ```{toctree} @@ -78,10 +81,11 @@ examples/tutorial_write_in_kernel.ipynb examples/explanation_interpolation.md examples/tutorial_interpolation.ipynb -examples/tutorial_peninsula_AvsCgrid.ipynb ``` + + ## Run a simulation @@ -93,6 +97,20 @@ examples/tutorial_peninsula_AvsCgrid.ipynb examples/tutorial_dt_integrators.ipynb ``` + + + + + + + + + + ## Example Kernels ```{toctree} @@ -105,12 +123,10 @@ 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/pixi.toml b/pixi.toml index 71035f952e..6ff79db06d 100644 --- a/pixi.toml +++ b/pixi.toml @@ -4,14 +4,14 @@ exclude-newer = "5d" # security pre-caution against compromised packages preview = ["pixi-build"] channels = ["conda-forge"] platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"] -requires-pixi = ">=0.72.0" +requires-pixi = ">=0.67.0,!=0.68.0,<0.71.0" [package] name = "parcels" version = "dynamic" # dynamic versioning needs better support in pixi https://github.com/prefix-dev/pixi/issues/2923#issuecomment-2598460666 . Putting `version = "dynamic"` here for now until pixi recommends something else. [package.build] -backend = { name = "pixi-build-python", version = "0.7.*" } +backend = { name = "pixi-build-python", version = "0.4.*" } [package.host-dependencies] setuptools = "*" @@ -30,7 +30,7 @@ holoviews = ">=1.22.0" # https://github.com/prefix-dev/rattler-build/issues/2326 uxarray = ">=2026.04.1" dask = ">=2024.5.1" zarr = ">=3" -xgcm = { git = "https://github.com/xgcm/xgcm", rev = "master" } # TODO: Switch to release version after release is cut +xgcm = { git = "https://github.com/xgcm/xgcm", rev = "532a3c567c475cbe192f49b9961f8117806bbfb7" } # TODO: Switch to release version after release is cut cf_xarray = ">=0.8.6" cftime = ">=1.6.3" pooch = ">=1.8.0" @@ -61,7 +61,7 @@ pyarrow = "20.0.*" uxarray = "==2026.04.1" dask = "2024.6.*" zarr = "3.0.*" -xgcm = { git = "https://github.com/xgcm/xgcm", rev = "master" } # TODO: Switch to release version after release is cut +xgcm = { version = "0.9.*", channel = "conda-forge" } cf_xarray = "0.10.*" cftime = "1.6.*" pooch = "1.8.*" diff --git a/src/parcels/__init__.py b/src/parcels/__init__.py index 81c901ca97..78227247b1 100644 --- a/src/parcels/__init__.py +++ b/src/parcels/__init__.py @@ -22,7 +22,6 @@ from parcels._core.basegrid import BaseGrid from parcels._core.uxgrid import UxGrid from parcels._core.xgrid import XGrid -from parcels._core.mesh import SphericalMesh from parcels._core.statuscodes import ( AllParcelsErrorCodes, @@ -55,7 +54,6 @@ "BaseGrid", "UxGrid", "XGrid", - "SphericalMesh", # Status codes and errors "AllParcelsErrorCodes", "FieldInterpolationError", diff --git a/src/parcels/_core/basegrid.py b/src/parcels/_core/basegrid.py index 7b7c7e9c49..ce4f88e3f1 100644 --- a/src/parcels/_core/basegrid.py +++ b/src/parcels/_core/basegrid.py @@ -8,7 +8,7 @@ import numpy as np -from parcels._core.mesh import FlatMesh, SphericalMesh +import parcels._typing as ptyping from parcels._core.spatialhash import SpatialHash if TYPE_CHECKING: @@ -26,7 +26,7 @@ class BaseGrid(ABC): """Base class for parcels.XGrid and parcels.UxGrid defining common methods and properties""" _spatialhash: SpatialHash | None - _mesh: FlatMesh | SphericalMesh + _mesh: ptyping.Mesh @abstractmethod def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int, float | np.ndarray]]: diff --git a/src/parcels/_core/field.py b/src/parcels/_core/field.py index 56a6917a0a..701a90d91b 100644 --- a/src/parcels/_core/field.py +++ b/src/parcels/_core/field.py @@ -109,9 +109,6 @@ def grid(self): # TODO PR: Remove in favour of referencing model grid directly @property def time_interval(self): # TODO PR: Remove in favour of referencing model time_interval directly - if "time" not in self.model.data[self.name].dims: - # This field does not have a time-dimension - return None return self.model.time_interval def __repr__(self): diff --git a/src/parcels/_core/fieldset.py b/src/parcels/_core/fieldset.py index f0e48577fc..940c98cf26 100644 --- a/src/parcels/_core/fieldset.py +++ b/src/parcels/_core/fieldset.py @@ -2,7 +2,6 @@ import functools import sys -import warnings from collections.abc import Iterable from typing import IO, TYPE_CHECKING @@ -22,7 +21,6 @@ from parcels._core.utils.string import _assert_str_and_python_varname from parcels._core.utils.time import get_datetime_type_calendar from parcels._core.utils.time import is_compatible as datetime_is_compatible -from parcels._core.warnings import FieldSetWarning from parcels._python import NOTSET, NotSetType from parcels._reprs import fieldset_describe from parcels.interpolators import ( @@ -75,7 +73,6 @@ def __init__(self, models: list[ModelData]): self._fields: dict[str, Field | VectorField] | None = None self.reconstruct_fields() self.context: dict[str, float] = {} - _warn_if_fields_use_different_meshes(self.fields.values()) def __setattr__(self, name, value): """Set field attribute by name. If context exists and name in context, raise error to prevent overwriting context variable.""" @@ -154,7 +151,6 @@ def add_field(self, field: Field, name: str | None = None): raise ValueError(f"FieldSet already has a Field with name '{name}'") self.fields[name] = field - _warn_if_fields_use_different_meshes(self.fields.values()) def to_windowed_arrays(self, *, max_levels: int | None = None): """Wrap dask-backed field data in rolling time-window caches. @@ -189,7 +185,7 @@ def to_windowed_arrays(self, *, max_levels: int | None = None): model.to_windowed_arrays(max_levels=max_levels) return self - def add_constant_field(self, name: str, value, mesh: ptyping.TMesh = "spherical"): + def add_constant_field(self, name: str, value, mesh: ptyping.Mesh = "spherical"): """Wrapper function to add a Field that is constant in space, useful e.g. when using constant horizontal diffusivity @@ -219,7 +215,6 @@ def add_constant_field(self, name: str, value, mesh: ptyping.TMesh = "spherical" self.reconstruct_fields() field = getattr(self, name) field.interp_method = XConstantField() - _warn_if_fields_use_different_meshes(self.fields.values()) def add_context(self, name, value): """Add context variable to the FieldSet. @@ -287,9 +282,8 @@ def from_ugrid_conventions( def from_sgrid_conventions( cls, ds: xr.Dataset, - mesh: ptyping.TMesh | None = None, + mesh: ptyping.Mesh | None = None, vector_fields: ptyping.VectorFields | NotSetType = NOTSET, - skip_field_data_validation: bool = False, ): # TODO: Update mesh to be discovered from the dataset metadata """Create a FieldSet from a dataset using SGRID convention metadata. @@ -308,8 +302,6 @@ def from_sgrid_conventions( Mapping of vector field names to tuples of component variable names in the dataset. For example, ``{"UV": ("U", "V"), "UVW": ("U", "V", "W")}``. If omitted (default), vector fields are auto-discovered from standard variable names (``U``/``V``/``W``). - skip_field_data_validation : bool, optional - If True, skip validation of the field data. This can be useful for performance reasons, but may lead to unexpected behavior if the field data is invalid. Default is False. Returns ------- @@ -324,9 +316,7 @@ def from_sgrid_conventions( See https://sgrid.github.io/sgrid/ for more information on the SGRID conventions. """ - model = StructuredModelData.from_sgrid_conventions( - ds, mesh, vector_fields, skip_field_data_validation=skip_field_data_validation - ) + model = StructuredModelData.from_sgrid_conventions(ds, mesh, vector_fields) return cls([model]) def describe(self, buf: IO | None = None) -> None: @@ -372,28 +362,6 @@ def assert_compatible_fieldsets(left: FieldSet, right: FieldSet) -> None: ) -def _warn_if_fields_use_different_meshes(fields: Iterable[Field | VectorField]): - """Warn if multiple fields use different meshes on the underlying grids. - - Parameters - ---------- - fields : Iterable[Field | VectorField] - The fields to check for conflicting meshes. - - Warns - ----- - FieldSetWarning - If the fields have different meshes on the underlying grids. - """ - meshes = {field.grid._mesh for field in fields} - if len(meshes) > 1: - warnings.warn( - f"FieldSet has multiple different meshes: {meshes}. This may lead to unexpected behavior during execution.", - category=FieldSetWarning, - stacklevel=3, - ) - - class CalendarError(Exception): # TODO: Move to a parcels errors module """Exception raised when the calendar of a field is not compatible with the rest of the Fields. The user should ensure that they only add fields to a FieldSet that have compatible CFtime calendars.""" diff --git a/src/parcels/_core/index_search.py b/src/parcels/_core/index_search.py index 77e43aff3e..5e84eee2e4 100644 --- a/src/parcels/_core/index_search.py +++ b/src/parcels/_core/index_search.py @@ -77,8 +77,8 @@ def _search_time_index(field: Field, time: np.ndarray): if field.time_interval is None: return { "T": { - "index": np.zeros(shape=np.atleast_1d(time).shape, dtype=np.int32), - "bcoord": np.zeros(shape=np.atleast_1d(time).shape, dtype=np.float32), + "index": np.zeros(shape=time.shape, dtype=np.int32), + "bcoord": np.zeros(shape=time.shape, dtype=np.float32), } } @@ -109,7 +109,7 @@ def curvilinear_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray dtype=float, ) - if grid._mesh.is_spherical(): + if grid._mesh == "spherical": xsi, eta = _bilinear_inverse_tangent_plane(clon, clat, x, y) is_in_cell = np.where((xsi >= 0) & (xsi <= 1) & (eta >= 0) & (eta <= 1), 1, 0) else: @@ -318,7 +318,7 @@ def uxgrid_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi: coords : np.ndarray Barycentric coordinates of the points within their respective cells. """ - if grid._mesh.is_spherical(): + if grid._mesh == "spherical": lon_rad = np.deg2rad(x) lat_rad = np.deg2rad(y) x_cart, y_cart, z_cart = _latlon_rad_to_xyz(lat_rad, lon_rad) diff --git a/src/parcels/_core/kernel.py b/src/parcels/_core/kernel.py index f68c8bf098..f50c2876ed 100644 --- a/src/parcels/_core/kernel.py +++ b/src/parcels/_core/kernel.py @@ -141,9 +141,9 @@ def check_fieldsets_in_kernels(self, kernel): # TODO v4: this can go into anoth stacklevel=2, ) self.fieldset.add_context("RK45_tol", 10) - if self.fieldset.U.grid._mesh.is_spherical(): + if self.fieldset.U.grid._mesh == "spherical": self.fieldset.RK45_tol /= ( - self.fieldset.U.grid.deg2m + 1852 * 60 ) # TODO does not account for zonal variation in meter -> degree conversion if not hasattr(self.fieldset, "RK45_min_dt"): warnings.warn( diff --git a/src/parcels/_core/mesh.py b/src/parcels/_core/mesh.py deleted file mode 100644 index 7ed5e49986..0000000000 --- a/src/parcels/_core/mesh.py +++ /dev/null @@ -1,79 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Literal - -import numpy as np - -EARTH_RADIUS = 6366707.019493707 - - -class BaseMesh(ABC): - radius: float | None - - @abstractmethod - def is_spherical(self) -> bool: ... - - def __eq__(self, other): - return self.is_spherical() == other.is_spherical() and self.radius == other.radius - - def __hash__(self): - return hash((self.is_spherical(), self.radius)) - - -class SphericalMesh(BaseMesh): - """Spherical mesh object with configurable planetary radius. - - Pass to FieldSet object as ``mesh=SphericalMesh(radius=...)``. - radius is in meters; defaults to Earth radius. - """ - - def __init__(self, radius: float = EARTH_RADIUS): - if not isinstance(radius, (int, float, np.number)): - raise TypeError(f"radius must be a number, got {type(radius).__name__}") - if radius <= 0: - raise ValueError(f"radius must be positive, got {radius}") - self.radius = radius - - @property - def deg2m(self) -> float: - """Meters per degree of arc.""" - assert self.radius is not None - return self.radius * np.pi / 180.0 - - def is_spherical(self): - return True - - def __repr__(self) -> str: - return f"SphericalMesh(radius={self.radius})" - - -class FlatMesh(BaseMesh): - """Flat mesh object.""" - - def __init__(self): - self.radius = None - return - - def __repr__(self) -> str: - return "FlatMesh()" - - def is_spherical(self): - return False - - -TMesh = SphericalMesh | Literal["spherical", "flat"] # corresponds with `mesh` - - -def get_mesh(mesh: TMesh): - if isinstance(mesh, SphericalMesh): - return mesh - if mesh == "flat": - return FlatMesh() - if mesh == "spherical": - return SphericalMesh(EARTH_RADIUS) - raise ValueError(f"mesh must be 'flat', 'spherical', or a SphericalMesh object. Got {mesh=!r}") - - -def is_spherical(mesh: FlatMesh | SphericalMesh): - if isinstance(mesh, SphericalMesh): - return True - return False diff --git a/src/parcels/_core/model.py b/src/parcels/_core/model.py index 9f31711d3f..79c9c64600 100644 --- a/src/parcels/_core/model.py +++ b/src/parcels/_core/model.py @@ -23,6 +23,7 @@ ) from parcels._logger import logger from parcels._python import NOTSET, NotSetType +from parcels._typing import Mesh from parcels.convert import _ds_rename_using_standard_names from parcels.interpolators import ( CGrid_Velocity, @@ -132,19 +133,11 @@ def preprocess_sgrid_model_data(ds: xr.Dataset) -> xr.Dataset: class StructuredModelData(ModelData): - def __init__( - self, - data: xr.Dataset, - mesh: ptyping.TMesh, - vector_field_components: ptyping.VectorFields, - skip_field_data_validation: bool = False, - ): + def __init__(self, data: xr.Dataset, mesh: Mesh, vector_field_components: ptyping.VectorFields): if not isinstance(data, xr.Dataset): raise ValueError(f"Expected `data` to be an xarray.Dataset . Got {type(data)}") data = preprocess_sgrid_model_data(data) - if not skip_field_data_validation: - data = data.fillna(0) grid = XGrid(data, mesh) self.data = data @@ -189,11 +182,7 @@ def construct_fields(self) -> list[Field | VectorField]: @classmethod def from_sgrid_conventions( - cls, - ds: xr.Dataset, - mesh: ptyping.TMesh | None, - vector_fields: ptyping.VectorFields | NotSetType, - skip_field_data_validation: bool = False, + cls, ds: xr.Dataset, mesh: Mesh | None, vector_fields: ptyping.VectorFields | NotSetType ) -> Self: ds = ds.copy() if mesh is None: @@ -224,12 +213,7 @@ def from_sgrid_conventions( vector_fields = resolve_vector_fields(ds, vector_fields) assert_valid_vector_fields(ds, vector_fields) - model = cls( - ds, - mesh=mesh, - vector_field_components=vector_fields, - skip_field_data_validation=skip_field_data_validation, - ) + model = cls(ds, mesh=mesh, vector_field_components=vector_fields) model._fields = model.construct_fields() for f in model._fields: if isinstance(f, Field): @@ -345,9 +329,7 @@ def scalar_field_names(self) -> list[str]: return list(self.data.data_vars) @classmethod - def from_ugrid_conventions( - cls, ds: ux.UxDataset, mesh: ptyping.TMesh, vector_fields: ptyping.VectorFields | NotSetType - ): + def from_ugrid_conventions(cls, ds: ux.UxDataset, mesh: Mesh, vector_fields: ptyping.VectorFields | NotSetType): ds_dims = list(ds.dims) if not all(dim in ds_dims for dim in ["time", "zf", "zc"]): raise ValueError( @@ -371,7 +353,7 @@ def from_ugrid_conventions( # TODO: Refactor later into something like `parcels._metadata.discover(dataset)` helper that can be used to discover important metadata like this. I think this whole metadata handling should be refactored into its own module. -def _get_mesh_type_from_sgrid_dataset(ds_sgrid: xr.Dataset) -> ptyping.TMesh: +def _get_mesh_type_from_sgrid_dataset(ds_sgrid: xr.Dataset) -> Mesh: """Small helper to inspect SGRID metadata and dataset metadata to determine mesh type.""" sgrid_metadata = ds_sgrid.sgrid.metadata diff --git a/src/parcels/_core/particlefile.py b/src/parcels/_core/particlefile.py index 50bdf309ce..64eb45e666 100644 --- a/src/parcels/_core/particlefile.py +++ b/src/parcels/_core/particlefile.py @@ -14,7 +14,6 @@ import xarray as xr import parcels -from parcels._core.mesh import BaseMesh from parcels._core.particle import ParticleClass from parcels._core.particlesetview import ParticleSetView from parcels._core.utils.time import timedelta_to_float @@ -120,14 +119,14 @@ def __init__( def __repr__(self) -> str: return particlefile_repr(self) - def set_metadata(self, parcels_grid_mesh: BaseMesh): + def set_metadata(self, parcels_grid_mesh: Literal["spherical", "flat"]): self.metadata.update( { "feature_type": "trajectory", "Conventions": "CF-1.6/CF-1.7", "ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0", "parcels_version": parcels.__version__, - "parcels_grid_mesh": repr(parcels_grid_mesh), + "parcels_grid_mesh": parcels_grid_mesh, } ) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index bcb11914aa..7d06968596 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -55,7 +55,7 @@ def __init__( if isinstance_noimport(grid, "XGrid"): self._coord_dim = 2 # Number of computational coordinates is 2 (bilinear interpolation) - if self._source_grid._mesh.is_spherical(): + if self._source_grid._mesh == "spherical": lon = np.deg2rad(self._source_grid.lon) lat = np.deg2rad(self._source_grid.lat) x, y, z = _latlon_rad_to_xyz(lat, lon) @@ -160,7 +160,7 @@ def __init__( elif isinstance_noimport(grid, "UxGrid"): self._coord_dim = grid.uxgrid.n_max_face_nodes # Number of barycentric coordinates - if self._source_grid._mesh.is_spherical(): + if self._source_grid._mesh == "spherical": # Reshape node coordinates to (nfaces, nnodes_per_face) nids = self._source_grid.uxgrid.face_node_connectivity.values lon = self._source_grid.uxgrid.node_lon.values[nids] @@ -404,7 +404,7 @@ def query(self, y, x): y = np.asarray(y) x = np.asarray(x) - if self._source_grid._mesh.is_spherical(): + if self._source_grid._mesh == "spherical": # Convert coords to Cartesian coordinates (x, y, z) lat = np.deg2rad(y) lon = np.deg2rad(x) diff --git a/src/parcels/_core/utils/interpolation.py b/src/parcels/_core/utils/interpolation.py index 5900c23803..a48c0ff217 100644 --- a/src/parcels/_core/utils/interpolation.py +++ b/src/parcels/_core/utils/interpolation.py @@ -3,7 +3,7 @@ import numpy as np -from parcels._core.mesh import BaseMesh +from parcels._typing import Mesh __all__ = [] @@ -61,12 +61,12 @@ def dphidxsi3D_lin(zeta: float, eta: float, xsi: float) -> tuple[list[float], li def dxdxsi3D_lin( - hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: BaseMesh, - deg2m: float = 1852 * 60.0 + hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: Mesh ) -> tuple[float, float, float, float, float, float, float, float, float]: dphidxsi, dphideta, dphidzet = dphidxsi3D_lin(zeta, eta, xsi) - if mesh.is_spherical(): + if mesh == 'spherical': + deg2m = 1852 * 60. rad = np.pi / 180. lat = (1-xsi) * (1-eta) * hexa_y[0] + \ xsi * (1-eta) * hexa_y[1] + \ @@ -92,10 +92,9 @@ def dxdxsi3D_lin( def jacobian3D_lin( - hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: BaseMesh, - deg2m: float = 1852 * 60.0 + hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: Mesh ) -> float: - dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh, deg2m) + dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh) jac = ( dxdxsi * (dydeta * dzdzet - dzdeta * dydzet) @@ -113,7 +112,7 @@ def jacobian3D_lin_face( eta: float, xsi: float, orientation: Literal["zonal", "meridional", "vertical"], - mesh: BaseMesh, + mesh: Mesh, ) -> float: dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh) @@ -175,11 +174,10 @@ def interpolate(phi: Callable[[float], list[float]], f: list[float], xsi: float) return np.dot(phi(xsi), f) -def _geodetic_distance( - lat1: float, lat2: float, lon1: float, lon2: float, mesh: BaseMesh, lat: float, deg2m: float = 1852 * 60.0 -) -> float: - if mesh.is_spherical(): +def _geodetic_distance(lat1: float, lat2: float, lon1: float, lon2: float, mesh: Mesh, lat: float) -> float: + if mesh == "spherical": rad = np.pi / 180.0 + deg2m = 1852 * 60.0 return np.sqrt(((lon2 - lon1) * deg2m * np.cos(rad * lat)) ** 2 + ((lat2 - lat1) * deg2m) ** 2) else: return np.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2) diff --git a/src/parcels/_core/uxgrid.py b/src/parcels/_core/uxgrid.py index 1b1e778ab0..39201c2e87 100644 --- a/src/parcels/_core/uxgrid.py +++ b/src/parcels/_core/uxgrid.py @@ -7,7 +7,7 @@ from parcels._core.basegrid import BaseGrid from parcels._core.index_search import GRID_SEARCH_ERROR, _search_1d_array, uxgrid_point_in_cell -from parcels._core.mesh import SphericalMesh, get_mesh +from parcels._typing import assert_valid_mesh _UXGRID_AXES = Literal["Z", "FACE"] @@ -18,9 +18,7 @@ class UxGrid(BaseGrid): for interpolation on unstructured grids. """ - def __init__( - self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh: Literal["flat", "spherical"] | SphericalMesh - ) -> None: + def __init__(self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh) -> None: """ Initializes the UxGrid with a uxarray grid and vertical coordinate array. @@ -43,9 +41,11 @@ def __init__( if z.ndim != 1: raise ValueError("z must be a 1D array of vertical coordinates") self.z = z - self._mesh = get_mesh(mesh) + self._mesh = mesh self._spatialhash = None + assert_valid_mesh(mesh) + @property def depth(self): """ @@ -73,13 +73,6 @@ def get_axis_dim(self, axis: _UXGRID_AXES) -> int: elif axis == "FACE": return self.uxgrid.n_face - @property - def deg2m(self) -> float: - """Metres per arcdegree for this grid's mesh.""" - if self._mesh.is_spherical(): - return self._mesh.deg2m - return 1.0 - def search(self, z, y, x, ei=None, tol=1e-6): """ Search for the grid cell (face) and vertical layer that contains the given points. diff --git a/src/parcels/_core/xgrid.py b/src/parcels/_core/xgrid.py index a109be6db1..36a9084759 100644 --- a/src/parcels/_core/xgrid.py +++ b/src/parcels/_core/xgrid.py @@ -1,7 +1,7 @@ from collections.abc import Hashable, Sequence from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import Any, Literal, cast import numpy as np import numpy.typing as npt @@ -13,27 +13,23 @@ import parcels._typing as ptyping from parcels._core.basegrid import BaseGrid from parcels._core.index_search import _search_1d_array, _search_indices_curvilinear_2d -from parcels._core.mesh import SphericalMesh, get_mesh from parcels._sgrid.accessor import _get_dim_to_axis_mapping from parcels._sgrid.core import SGRID_PADDING_TO_XGCM_POSITION -if TYPE_CHECKING: - import xgcm.axis - _FIELD_DATA_ORDERING: Sequence[ptyping.XgcmAxisDirection] = "TZYX" _XGRID_AXES_ORDERING: Sequence[ptyping.XgridAxis] = "ZYX" -_DEFAULT_XGCM_KWARGS: dict[str, Any] = {"padding": "fill"} +_DEFAULT_XGCM_KWARGS: dict[str, Any] = {"periodic": False} -def get_cell_count_along_dim(ds: xr.Dataset, axis: xgcm.axis.Axis) -> int: +def get_cell_count_along_dim(ds: xr.Dataset, axis: xgcm.Axis) -> int: first_coord = list(axis.coords.items())[0] _, coord_var = first_coord return ds[coord_var].size - 1 -def get_time(ds: xr.Dataset, axis: xgcm.axis.Axis) -> npt.NDArray: +def get_time(ds: xr.Dataset, axis: xgcm.Axis) -> npt.NDArray: return ds[axis.coords["center"]].values @@ -169,12 +165,12 @@ class XGrid(BaseGrid): """ - def __init__(self, model_data: xr.Dataset, mesh: Literal["flat", "spherical"] | SphericalMesh): + def __init__(self, model_data: xr.Dataset, mesh): self.sgrid_metadata = model_data.sgrid.metadata self._ds = model_data grid = XgcmLikeGrid(self.sgrid_metadata, model_data) self.xgcm_grid = grid - self._mesh = get_mesh(mesh) + self._mesh = mesh self._spatialhash = None ds = model_data @@ -190,6 +186,7 @@ def __init__(self, model_data: xr.Dataset, mesh: Literal["flat", "spherical"] | if "Z" in grid.axes: assert_valid_depth(ds["depth"]) + ptyping.assert_valid_mesh(mesh) self._ds = ds # def __repr__(self): @@ -259,13 +256,6 @@ def _datetimes(self): def time(self): return self._datetimes.astype(np.float64) / 1e9 - @property - def deg2m(self) -> float: - """Metres per degree of arc for this grid's mesh.""" - if self._mesh.is_spherical(): - return self._mesh.deg2m - return 1.0 - @cached_property def xdim(self) -> int: return self.get_axis_dim("X") diff --git a/src/parcels/_datasets/remote.py b/src/parcels/_datasets/remote.py index 3b8d83280f..5fc93868a8 100644 --- a/src/parcels/_datasets/remote.py +++ b/src/parcels/_datasets/remote.py @@ -50,7 +50,6 @@ 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", @@ -221,7 +220,6 @@ 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 7eba61f559..23b3f8299f 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(0, domainsizeX, xdim, dtype=np.float32) - Wa = np.linspace(0, domainsizeY, ydim, dtype=np.float32) + La = np.linspace(1e3, domainsizeX, xdim, dtype=np.float32) + Wa = np.linspace(1e3, 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 = ["YC", "XC"] - Vdims = ["YC", "XC"] + Udims = ["YG", "XG"] + Vdims = ["YG", "XG"] elif grid_type == "C": U = np.zeros(P.shape) V = np.zeros(P.shape) - U[1:, :] = -(P[1:, :] - P[:-1, :]) / (Wa[1] - Wa[0]) V[:, 1:] = (P[:, 1:] - P[:, :-1]) / (La[1] - La[0]) - Udims = ["YG", "XC"] - Vdims = ["YC", "XG"] + U[1:, :] = -(P[1:, :] - P[:-1, :]) / (Wa[1] - Wa[0]) + Udims = ["YC", "XG"] + Vdims = ["YG", "XC"] 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": (["YC", "XC"], P), + "P": (["YG", "XG"], P), }, coords={ - "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"}), + "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}), }, ).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.LOW), + sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.HIGH), ), ), ) @@ -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": - U[1:, :] = -(P[1:, :] - P[0:-1, :]) / dy * b V[:, 1:] = (P[:, 1:] - P[:, 0:-1]) / dx * a - Udims = ["YG", "XC"] - Vdims = ["YC", "XG"] + U[1:, :] = -(P[1:, :] - P[0:-1, :]) / dy * b + Udims = ["YC", "XG"] + Vdims = ["YG", "XC"] else: - Udims = ["YC", "XC"] - Vdims = ["YC", "XC"] + Udims = ["YG", "XG"] + Vdims = ["YG", "XG"] return xr.Dataset( {"U": (Udims, U), "V": (Vdims, V), "P": (["YG", "XG"], P)}, coords={ - "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"}), + "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}), }, ).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.LOW), + sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.HIGH), ), ), ) diff --git a/src/parcels/_typing.py b/src/parcels/_typing.py index 87e23a53cf..e8993cb058 100644 --- a/src/parcels/_typing.py +++ b/src/parcels/_typing.py @@ -9,13 +9,11 @@ import os from collections.abc import Callable, Mapping from datetime import datetime -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Any, Literal, get_args import numpy as np from cftime import datetime as cftime_datetime -from parcels._core.mesh import TMesh # noqa: F401 - if TYPE_CHECKING: import xgcm @@ -35,6 +33,7 @@ InterpMethodOption | dict[str, InterpMethodOption] ) # corresponds with `interp_method` (which can also be dict mapping field names to method) PathLike = str | os.PathLike +Mesh = Literal["spherical", "flat"] # corresponds with `mesh` VectorType = Literal["3D", "3DSigma", "2D"] | None # corresponds with `vector_type` GridIndexingType = Literal["pop", "mom5", "mitgcm", "nemo", "croco"] # corresponds with `gridindexingtype` NetcdfEngine = Literal["netcdf4", "xarray", "scipy"] @@ -70,3 +69,8 @@ def _validate_against_pure_literal(value, typing_literal): if value not in get_args(typing_literal): msg = f"Invalid value {value!r}. Valid options are {get_args(typing_literal)!r}" raise ValueError(msg) + + +# Assertion functions to clean user input +def assert_valid_mesh(value: Any): + _validate_against_pure_literal(value, Mesh) diff --git a/src/parcels/convert.py b/src/parcels/convert.py index b9eeffa98e..c8cea9ce39 100644 --- a/src/parcels/convert.py +++ b/src/parcels/convert.py @@ -241,13 +241,6 @@ def _maybe_swap_depth_direction(ds: xr.Dataset) -> xr.Dataset: return ds -def _maybe_expand_depth_dimension(ds: xr.Dataset) -> xr.Dataset: - if "depth" not in ds.dims: - ds = ds.expand_dims(dim={"depth": [0]}, axis=1) - logger.info("No depth dimension found in dataset. Added a singleton depth dimension.") - return ds - - # TODO is this function still needed, now that we require users to provide field names explicitly? def _discover_U_and_V(ds: xr.Dataset, cf_standard_names_fallbacks) -> xr.Dataset: # Assumes that the dataset has U and V data @@ -548,8 +541,6 @@ def copernicusmarine_to_sgrid( ds.attrs.clear() # Clear global attributes from the merging ds = _maybe_rename_coords(ds, _COPERNICUS_MARINE_AXIS_VARNAMES) - ds = _maybe_expand_depth_dimension(ds) - if "W" in ds.data_vars: # Negate W to convert from up positive to down positive (as that's the direction of positive z) ds["W"].data *= -1 diff --git a/src/parcels/interpolators/_uxinterpolators.py b/src/parcels/interpolators/_uxinterpolators.py index 17249bfdc5..80e804475c 100644 --- a/src/parcels/interpolators/_uxinterpolators.py +++ b/src/parcels/interpolators/_uxinterpolators.py @@ -170,9 +170,9 @@ def interp( ): u = vectorfield.U.interp_method.interp(particle_positions, grid_positions, vectorfield.U) v = vectorfield.V.interp_method.interp(particle_positions, grid_positions, vectorfield.V) - if vectorfield.grid._mesh.is_spherical(): - u /= vectorfield.grid.deg2m * np.cos(np.deg2rad(particle_positions["y"])) - v /= vectorfield.grid.deg2m + if vectorfield.grid._mesh == "spherical": + u /= 1852 * 60 * np.cos(np.deg2rad(particle_positions["y"])) + v /= 1852 * 60 if "3D" in vectorfield.vector_type: w = vectorfield.W.interp_method.interp(particle_positions, grid_positions, vectorfield.W) diff --git a/src/parcels/interpolators/_xinterpolators.py b/src/parcels/interpolators/_xinterpolators.py index 54606c4b04..388925d1a8 100644 --- a/src/parcels/interpolators/_xinterpolators.py +++ b/src/parcels/interpolators/_xinterpolators.py @@ -148,9 +148,9 @@ def interp( _xlinear = XLinear() u = _xlinear.interp(particle_positions, grid_positions, vectorfield.U) v = _xlinear.interp(particle_positions, grid_positions, vectorfield.V) - if vectorfield.grid._mesh.is_spherical(): - u /= vectorfield.grid.deg2m * np.cos(np.deg2rad(particle_positions["y"])) - v /= vectorfield.grid.deg2m + if vectorfield.grid._mesh == "spherical": + u /= 1852 * 60 * np.cos(np.deg2rad(particle_positions["y"])) + v /= 1852 * 60 if vectorfield.W: w = _xlinear.interp(particle_positions, grid_positions, vectorfield.W) @@ -196,21 +196,21 @@ def interp( px = np.array([grid.lon[yi, xi], grid.lon[yi, xi + 1], grid.lon[yi + 1, xi + 1], grid.lon[yi + 1, xi]]) py = np.array([grid.lat[yi, xi], grid.lat[yi, xi + 1], grid.lat[yi + 1, xi + 1], grid.lat[yi + 1, xi]]) - if grid._mesh.is_spherical(): + if grid._mesh == "spherical": px = ((px + 180.0) % 360.0) - 180.0 px[1:] = np.where(px[1:] - px[0] > 180, px[1:] - 360, px[1:]) px[1:] = np.where(-px[1:] + px[0] > 180, px[1:] + 360, px[1:]) c1 = i_u._geodetic_distance( - py[0], py[1], px[0], px[1], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(0.0, xsi), py), grid.deg2m + py[0], py[1], px[0], px[1], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(0.0, xsi), py) ) c2 = i_u._geodetic_distance( - py[1], py[2], px[1], px[2], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(eta, 1.0), py), grid.deg2m + py[1], py[2], px[1], px[2], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(eta, 1.0), py) ) c3 = i_u._geodetic_distance( - py[2], py[3], px[2], px[3], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(1.0, xsi), py), grid.deg2m + py[2], py[3], px[2], px[3], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(1.0, xsi), py) ) c4 = i_u._geodetic_distance( - py[3], py[0], px[3], px[0], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(eta, 0.0), py), grid.deg2m + py[3], py[0], px[3], px[0], grid._mesh, np.einsum("ij,ji->i", i_u.phi2D_lin(eta, 0.0), py) ) def _create_selection_dict(dims, zdir=False): @@ -282,8 +282,8 @@ def _compute_corner_data(data, selection_dict) -> np.ndarray: V1 = corner_data[1, :] * c3 Vvel = (1 - eta) * V0 + eta * V1 - if grid._mesh.is_spherical(): - jac = i_u._compute_jacobian_determinant(py, px, eta, xsi) * grid.deg2m + if grid._mesh == "spherical": + jac = i_u._compute_jacobian_determinant(py, px, eta, xsi) * 1852 * 60.0 else: jac = i_u._compute_jacobian_determinant(py, px, eta, xsi) @@ -303,8 +303,8 @@ def _compute_corner_data(data, selection_dict) -> np.ndarray: u = u.compute() v = v.compute() - if grid._mesh.is_spherical(): - conversion = grid.deg2m * np.cos(np.deg2rad(particle_positions["y"])) + if grid._mesh == "spherical": + conversion = 1852 * 60.0 * np.cos(np.deg2rad(particle_positions["y"])) u /= conversion v /= conversion @@ -425,64 +425,84 @@ 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: - land = is_land(0, 0, 0, 1) & is_land(0, 0, 1, 1) & (xsi < 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) else: - 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 + 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, + ) + u *= f_u + v *= f_v if vectorfield.W: f_w = np.ones_like(zeta) - 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]) - - 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]) + 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, + ) - w = w * f_w + w *= f_w else: w = np.zeros_like(u) - return u, v, w @@ -628,7 +648,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/src/parcels/kernels/_advection.py b/src/parcels/kernels/_advection.py index 77162aad59..05df14c395 100644 --- a/src/parcels/kernels/_advection.py +++ b/src/parcels/kernels/_advection.py @@ -223,12 +223,12 @@ def AdvectionAnalytical(particles, fieldset): # pragma: no cover else: dz = 1.0 - c1 = i_u._geodetic_distance(py[0], py[1], px[0], px[1], grid.mesh, np.dot(i_u.phi2D_lin(0.0, xsi), py), grid.deg2m) - c2 = i_u._geodetic_distance(py[1], py[2], px[1], px[2], grid.mesh, np.dot(i_u.phi2D_lin(eta, 1.0), py), grid.deg2m) - c3 = i_u._geodetic_distance(py[2], py[3], px[2], px[3], grid.mesh, np.dot(i_u.phi2D_lin(1.0, xsi), py), grid.deg2m) - c4 = i_u._geodetic_distance(py[3], py[0], px[3], px[0], grid.mesh, np.dot(i_u.phi2D_lin(eta, 0.0), py), grid.deg2m) + c1 = i_u._geodetic_distance(py[0], py[1], px[0], px[1], grid.mesh, np.dot(i_u.phi2D_lin(0.0, xsi), py)) + c2 = i_u._geodetic_distance(py[1], py[2], px[1], px[2], grid.mesh, np.dot(i_u.phi2D_lin(eta, 1.0), py)) + c3 = i_u._geodetic_distance(py[2], py[3], px[2], px[3], grid.mesh, np.dot(i_u.phi2D_lin(1.0, xsi), py)) + c4 = i_u._geodetic_distance(py[3], py[0], px[3], px[0], grid.mesh, np.dot(i_u.phi2D_lin(eta, 0.0), py)) rad = np.pi / 180.0 - deg2m = grid.deg2m + deg2m = 1852 * 60.0 meshJac = (deg2m * deg2m * math.cos(rad * particles.y)) if grid.mesh == "spherical" else 1 dxdy = i_u._compute_jacobian_determinant(py, px, eta, xsi) * meshJac diff --git a/src/parcels/kernels/_advectiondiffusion.py b/src/parcels/kernels/_advectiondiffusion.py index b5d327de93..3593e2538a 100644 --- a/src/parcels/kernels/_advectiondiffusion.py +++ b/src/parcels/kernels/_advectiondiffusion.py @@ -8,14 +8,14 @@ __all__ = ["AdvectionDiffusionEM", "AdvectionDiffusionM1", "DiffusionUniformKh"] -def meters_to_degrees_zonal(deg, lat, deg2m): # pragma: no cover +def meters_to_degrees_zonal(deg, lat): # pragma: no cover """Convert square meters to square degrees longitude at a given latitude.""" - return deg / pow(deg2m * np.cos(lat * np.pi / 180), 2) + return deg / pow(1852 * 60.0 * np.cos(lat * np.pi / 180), 2) -def meters_to_degrees_meridional(deg, deg2m): # pragma: no cover +def meters_to_degrees_meridional(deg): # pragma: no cover """Convert square meters to square degrees latitude.""" - return deg / pow(deg2m, 2) + return deg / pow(1852 * 60.0, 2) def AdvectionDiffusionM1(particles, fieldset): # pragma: no cover @@ -39,27 +39,27 @@ def AdvectionDiffusionM1(particles, fieldset): # pragma: no cover Kxp1 = fieldset.Kh_zonal[particles.t, particles.z, particles.y, particles.x + fieldset.dres, particles] Kxm1 = fieldset.Kh_zonal[particles.t, particles.z, particles.y, particles.x - fieldset.dres, particles] - if fieldset.Kh_zonal.grid._mesh.is_spherical(): - Kxp1 = meters_to_degrees_zonal(Kxp1, particles.y, fieldset.Kh_zonal.grid.deg2m) - Kxm1 = meters_to_degrees_zonal(Kxm1, particles.y, fieldset.Kh_zonal.grid.deg2m) + if fieldset.Kh_zonal.grid._mesh == "spherical": + Kxp1 = meters_to_degrees_zonal(Kxp1, particles.y) + Kxm1 = meters_to_degrees_zonal(Kxm1, particles.y) dKdx = (Kxp1 - Kxm1) / (2 * fieldset.dres) u, v = fieldset.UV[particles.t, particles.z, particles.y, particles.x, particles] kh_zonal = fieldset.Kh_zonal[particles.t, particles.z, particles.y, particles.x, particles] - if fieldset.Kh_zonal.grid._mesh.is_spherical(): - kh_zonal = meters_to_degrees_zonal(kh_zonal, particles.y, fieldset.Kh_zonal.grid.deg2m) + if fieldset.Kh_zonal.grid._mesh == "spherical": + kh_zonal = meters_to_degrees_zonal(kh_zonal, particles.y) bx = np.sqrt(2 * kh_zonal) Kyp1 = fieldset.Kh_meridional[particles.t, particles.z, particles.y + fieldset.dres, particles.x, particles] Kym1 = fieldset.Kh_meridional[particles.t, particles.z, particles.y - fieldset.dres, particles.x, particles] - if fieldset.Kh_meridional.grid._mesh.is_spherical(): - Kyp1 = meters_to_degrees_meridional(Kyp1, fieldset.Kh_meridional.grid.deg2m) - Kym1 = meters_to_degrees_meridional(Kym1, fieldset.Kh_meridional.grid.deg2m) + if fieldset.Kh_meridional.grid._mesh == "spherical": + Kyp1 = meters_to_degrees_meridional(Kyp1) + Kym1 = meters_to_degrees_meridional(Kym1) dKdy = (Kyp1 - Kym1) / (2 * fieldset.dres) kh_meridional = fieldset.Kh_meridional[particles.t, particles.z, particles.y, particles.x, particles] - if fieldset.Kh_meridional.grid._mesh.is_spherical(): - kh_meridional = meters_to_degrees_meridional(kh_meridional, fieldset.Kh_meridional.grid.deg2m) + if fieldset.Kh_meridional.grid._mesh == "spherical": + kh_meridional = meters_to_degrees_meridional(kh_meridional) by = np.sqrt(2 * kh_meridional) # Particle positions are updated only after evaluating all terms. @@ -88,28 +88,28 @@ def AdvectionDiffusionEM(particles, fieldset): # pragma: no cover Kxp1 = fieldset.Kh_zonal[particles.t, particles.z, particles.y, particles.x + fieldset.dres, particles] Kxm1 = fieldset.Kh_zonal[particles.t, particles.z, particles.y, particles.x - fieldset.dres, particles] - if fieldset.Kh_zonal.grid._mesh.is_spherical(): - Kxp1 = meters_to_degrees_zonal(Kxp1, particles.y, fieldset.Kh_zonal.grid.deg2m) - Kxm1 = meters_to_degrees_zonal(Kxm1, particles.y, fieldset.Kh_zonal.grid.deg2m) + if fieldset.Kh_zonal.grid._mesh == "spherical": + Kxp1 = meters_to_degrees_zonal(Kxp1, particles.y) + Kxm1 = meters_to_degrees_zonal(Kxm1, particles.y) dKdx = (Kxp1 - Kxm1) / (2 * fieldset.dres) ax = u + dKdx kh_zonal = fieldset.Kh_zonal[particles.t, particles.z, particles.y, particles.x, particles] - if fieldset.Kh_zonal.grid._mesh.is_spherical(): - kh_zonal = meters_to_degrees_zonal(kh_zonal, particles.y, fieldset.Kh_zonal.grid.deg2m) + if fieldset.Kh_zonal.grid._mesh == "spherical": + kh_zonal = meters_to_degrees_zonal(kh_zonal, particles.y) bx = np.sqrt(2 * kh_zonal) Kyp1 = fieldset.Kh_meridional[particles.t, particles.z, particles.y + fieldset.dres, particles.x, particles] Kym1 = fieldset.Kh_meridional[particles.t, particles.z, particles.y - fieldset.dres, particles.x, particles] - if fieldset.Kh_meridional.grid._mesh.is_spherical(): - Kyp1 = meters_to_degrees_meridional(Kyp1, fieldset.Kh_meridional.grid.deg2m) - Kym1 = meters_to_degrees_meridional(Kym1, fieldset.Kh_meridional.grid.deg2m) + if fieldset.Kh_meridional.grid._mesh == "spherical": + Kyp1 = meters_to_degrees_meridional(Kyp1) + Kym1 = meters_to_degrees_meridional(Kym1) dKdy = (Kyp1 - Kym1) / (2 * fieldset.dres) ay = v + dKdy kh_meridional = fieldset.Kh_meridional[particles.t, particles.z, particles.y, particles.x, particles] - if fieldset.Kh_meridional.grid._mesh.is_spherical(): - kh_meridional = meters_to_degrees_meridional(kh_meridional, fieldset.Kh_meridional.grid.deg2m) + if fieldset.Kh_meridional.grid._mesh == "spherical": + kh_meridional = meters_to_degrees_meridional(kh_meridional) by = np.sqrt(2 * kh_meridional) # Particle positions are updated only after evaluating all terms. @@ -142,9 +142,9 @@ def DiffusionUniformKh(particles, fieldset): # pragma: no cover kh_zonal = fieldset.Kh_zonal[particles] kh_meridional = fieldset.Kh_meridional[particles] - if fieldset.Kh_zonal.grid._mesh.is_spherical(): - kh_zonal = meters_to_degrees_zonal(kh_zonal, particles.y, fieldset.Kh_zonal.grid.deg2m) - kh_meridional = meters_to_degrees_meridional(kh_meridional, fieldset.Kh_meridional.grid.deg2m) + if fieldset.Kh_zonal.grid._mesh == "spherical": + kh_zonal = meters_to_degrees_zonal(kh_zonal, particles.y) + kh_meridional = meters_to_degrees_meridional(kh_meridional) bx = np.sqrt(2 * kh_zonal) by = np.sqrt(2 * kh_meridional) diff --git a/tests/test_advection.py b/tests/test_advection.py index 0a033f2131..8e44301bf2 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, 1e-2), + (AdvectionRK4, 5e-3), (AdvectionRK45, 1e-3), ], ) -@pytest.mark.parametrize("grid_type", ["A", "C"]) +@pytest.mark.parametrize("grid_type", ["A"]) # TODO also implement C-grid once available def test_peninsula_fieldset(kernel, rtol, grid_type): npart = 2 ds = peninsula_dataset(grid_type=grid_type) diff --git a/tests/test_convert.py b/tests/test_convert.py index 2d4fbc3b3d..6a05bc9602 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -174,14 +174,6 @@ def test_convert_copernicusmarine_no_logs(ds, caplog): assert caplog.text == "" -def test_convert_copernicusmarine_nodepth(caplog): - ds = datasets_circulation_models["ds_copernicusmarine"] - ds = ds.isel(depth=0).drop_vars("depth") - ds_fset = convert.copernicusmarine_to_sgrid(fields={"uo": ds["uo"]}) - FieldSet.from_sgrid_conventions(ds_fset) - assert "No depth dimension found in dataset. Added a singleton depth dimension." in caplog.text - - def test_convert_fesom_to_ugrid(): grid_file = open_remote_dataset("Benchmarks_FESOM2-baroclinic-gyre/grid") data_files = open_remote_dataset("Benchmarks_FESOM2-baroclinic-gyre/data") diff --git a/tests/test_fieldset.py b/tests/test_fieldset.py index 26310f7031..5ec45ac0dd 100644 --- a/tests/test_fieldset.py +++ b/tests/test_fieldset.py @@ -6,7 +6,6 @@ import numpy as np import pandas as pd import pytest -import xarray as xr import parcels.tutorial from parcels import Field, ParticleFile, ParticleSet, XGrid, convert, open_raw_zarr @@ -32,7 +31,7 @@ def fieldset_two_models(): fset2 = FieldSet.from_sgrid_conventions(ds2, mesh="flat", vector_fields={"UV_wind": ("U_wind", "V_wind")}) fset2.add_context("my_value", 2.0) fset2.add_context("my_list", [1, 2, "hello"]) - fset2.add_constant_field("constant_field", 3.0, mesh="flat") + fset2.add_constant_field("constant_field", 3.0) return fset1 + fset2 @@ -69,7 +68,7 @@ def test_fieldset_add_context_invalid_name(fieldset, name): def test_fieldset_add_constant_field(fieldset): - fieldset.add_constant_field("test_constant_field", 1.0, mesh="flat") + fieldset.add_constant_field("test_constant_field", 1.0) # Get a point in the domain time = ds["time"].mean() @@ -86,7 +85,7 @@ def test_fieldset_gridset(fieldset): assert fieldset.fields["UV"].grid in fieldset.gridset assert len(fieldset.gridset) == 1 - fieldset.add_constant_field("constant_field", 1.0, mesh="flat") + fieldset.add_constant_field("constant_field", 1.0) assert len(fieldset.gridset) == 2 @@ -233,7 +232,7 @@ def test_fieldset_time_interval(): field2 = Field("field2", ds2["U_A_grid"], grid2, interp_method=XLinear) fieldset = FieldSet([field1, field2]) - fieldset.add_constant_field("constant_field", 1.0, mesh="flat") + fieldset.add_constant_field("constant_field", 1.0) assert fieldset.time_interval.left == np.datetime64("2000-01-02") assert fieldset.time_interval.right == np.datetime64("2001-01-01") @@ -330,16 +329,6 @@ def test_fieldset_from_sgrid_conventions(ds_name): assert len(fieldset.fields) > 0 -def test_fieldset_skip_field_data_validation(): - ds = datasets_structured["ds_2d_left"] - ds["U_A_grid"][:] = np.nan - - fieldset = FieldSet.from_sgrid_conventions(ds, mesh="flat") - assert np.isfinite(fieldset.U_A_grid.data).all() - fieldset = FieldSet.from_sgrid_conventions(ds, mesh="flat", skip_field_data_validation=True) - assert np.isnan(fieldset.U_A_grid.data).all() - - def test_fieldset_add_error_on_duplicate_fields(): """Test that adding FieldSets with overlapping field names raises a ValueError.""" ds1 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename({"U_A_grid": "U", "V_A_grid": "V"}) @@ -371,17 +360,6 @@ def test_fieldset_add(): assert set(fields_before) == set(fset.fields.keys()) -def test_vectorfields_without_time(): - """Test that vector fields without a time dimension can be evaluated.""" - ds1 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename({"U_A_grid": "U", "V_A_grid": "V"}) - ds2 = ds1.isel(time=0).drop_vars("time").rename({"U": "U_const", "V": "V_const"}) - ds = xr.merge([ds1, ds2]) - - fset = FieldSet.from_sgrid_conventions(ds, mesh="flat", vector_fields={"UV_const": ("U_const", "V_const")}) - fset.UV_const.eval(t=0, z=0, y=0, x=0) - fset.U_const.eval(t=0, z=0, y=0, x=0) - - def test_fieldset_add_error_on_duplicate_context_values(): """Test that adding FieldSets with overlapping context value names raises a ValueError.""" ds1 = datasets_structured["ds_2d_left"][["U_A_grid", "grid"]].rename({"U_A_grid": "U1"}) @@ -464,7 +442,7 @@ def test_fieldset_describe_backends(tmp_path): | UV | VectorField | 0 | CGrid_Velocity(...) | - | | UVW | VectorField | 0 | CGrid_Velocity(...) | - | -mesh: SphericalMesh(radius=6366707.019493707) +mesh: spherical time interval: (np.datetime64('2000-01-02T12:00:00.000000000'), np.datetime64('2000-01-12T12:00:00.000000000')) """ fieldset.describe(io) @@ -484,7 +462,7 @@ def test_fieldset_describe_backends(tmp_path): | UV | VectorField | 0 | CGrid_Velocity(...) | - | | UVW | VectorField | 0 | CGrid_Velocity(...) | - | -mesh: SphericalMesh(radius=6366707.019493707) +mesh: spherical time interval: (np.datetime64('2000-01-02T12:00:00.000000000'), np.datetime64('2000-01-12T12:00:00.000000000')) """ fieldset.describe(io) @@ -500,13 +478,13 @@ def test_fieldset_describe_backends(tmp_path): expected = """\ | Name | Type | Grid number | Interp method / value | Parcels backend | |:-------|:------------|--------------:|:------------------------|:------------------| -| U | Field | 0 | XLinear(...) | NumPy | -| V | Field | 0 | XLinear(...) | NumPy | -| W | Field | 0 | XLinear(...) | NumPy | +| U | Field | 0 | XLinear(...) | Zarr | +| V | Field | 0 | XLinear(...) | Zarr | +| W | Field | 0 | XLinear(...) | Zarr | | UV | VectorField | 0 | CGrid_Velocity(...) | - | | UVW | VectorField | 0 | CGrid_Velocity(...) | - | -mesh: SphericalMesh(radius=6366707.019493707) +mesh: spherical time interval: (np.datetime64('2000-01-02T12:00:00.000000000'), np.datetime64('2000-01-12T12:00:00.000000000')) """ fieldset.describe(io) diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py index ba78da6790..a97e2cd879 100644 --- a/tests/test_interpolation.py +++ b/tests/test_interpolation.py @@ -13,7 +13,6 @@ 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, @@ -114,28 +113,26 @@ 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.0], [1.0]]), - (XFreeslip(), 1, 0, 0.5, 1.5, [[1.0], [0.5]]), + (XPartialslip(), 1, 0, 0, 0.0, [[1], [1]]), + (XFreeslip(), 1, 0, 0.5, 1.5, [[1], [0.5]]), (XPartialslip(), 1, 0, 2.5, 1.5, [[0.75], [0.5]]), - (XFreeslip(), 1, 0, 2.5, 1.5, [[1.0], [0.5]]), + (XFreeslip(), 1, 0, 2.5, 1.5, [[1], [0.5]]), (XPartialslip(), 1, 0, 1.5, 0.5, [[0.5], [0.75]]), - (XFreeslip(), 1, 0, 1.5, 0.5, [[0.5], [1.0]]), + (XFreeslip(), 1, 0, 1.5, 0.5, [[0.5], [1]]), ( XFreeslip(), [1, 0], [0, 2], [1.5, 1.5], [2.5, 0.5], - [[0.5, 0.5], [1.0, 1.0]], + [[0.5, 0.5], [1, 1]], ), ], ) -def test_spatial_slip_interpolation(field, func, t, z, y, x, expected, mesh): - field.grid._mesh = get_mesh(mesh) +def test_spatial_slip_interpolation(field, func, t, z, y, x, expected): field.data[:] = 1.0 field.data[:, :, 1:3, 1:3] = 0.0 # Set zero land value to test spatial slip U = field @@ -143,10 +140,6 @@ def test_spatial_slip_interpolation(field, func, t, z, y, x, expected, mesh): 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) diff --git a/tests/test_mesh.py b/tests/test_mesh.py deleted file mode 100644 index 9a0a4b709e..0000000000 --- a/tests/test_mesh.py +++ /dev/null @@ -1,84 +0,0 @@ -import numpy as np -import pytest - -from parcels import FieldSet, ParticleSet, SphericalMesh -from parcels._core.mesh import EARTH_RADIUS -from parcels._datasets.structured.generated import simple_UV_dataset -from parcels.kernels import AdvectionRK4 - -EARTH_DEG2M = EARTH_RADIUS * np.pi / 180 - - -def test_spherical_mesh_deg2m(): - assert SphericalMesh().radius == EARTH_RADIUS - assert SphericalMesh().deg2m == EARTH_DEG2M - r = 3389500.0 # Mars radius - assert SphericalMesh(radius=r).deg2m == pytest.approx(r * np.pi / 180) - - -@pytest.mark.parametrize( - "mesh, exp_radius, exp_deg2m", - [ - ("spherical", EARTH_RADIUS, EARTH_DEG2M), - (SphericalMesh(), EARTH_RADIUS, EARTH_DEG2M), - (SphericalMesh(radius=3389500.0), 3389500.0, 3389500.0 * np.pi / 180), - ], -) -def test_xgrid_radius_and_deg2m(mesh, exp_radius, exp_deg2m): - grid = FieldSet.from_sgrid_conventions(simple_UV_dataset(), mesh=mesh).U.grid - assert isinstance(grid._mesh, SphericalMesh) - assert grid._mesh.radius == exp_radius - assert grid.deg2m == pytest.approx(exp_deg2m) - - -@pytest.mark.parametrize( - "mesh, deg2m", - [ - (SphericalMesh(), EARTH_DEG2M), # No radius entered - (SphericalMesh(radius=3389500.0), 3389500.0 * np.pi / 180), # Mars - (SphericalMesh(radius=6051800.0), 6051800.0 * np.pi / 180), # Venus - (SphericalMesh(radius=EARTH_RADIUS), EARTH_DEG2M), # Explicit Earth - ], -) -def test_advection_uses_custom_radius(mesh, deg2m, npart=10): - ds = simple_UV_dataset() - ds["U"].data[:] = 1.0 - fieldset = FieldSet.from_sgrid_conventions(ds, mesh=mesh) - - runtime = 7200 - startlat = np.linspace(0, 80, npart) - startlon = 20.0 + np.zeros(npart) - pset = ParticleSet(fieldset, x=startlon, y=startlat) - pset.execute(AdvectionRK4, runtime=runtime, dt=np.timedelta64(15, "m")) - - expected_dlon = runtime / (deg2m * np.cos(np.deg2rad(pset.y))) - np.testing.assert_allclose(pset.x - startlon, expected_dlon, atol=1e-5) - np.testing.assert_allclose(pset.y, startlat, atol=1e-5) - - -def test_advection_flat_mesh(npart=10): - ds = simple_UV_dataset(mesh="flat") - ds["U"].data[:] = 1.0 - fieldset = FieldSet.from_sgrid_conventions(ds, mesh="flat") - - runtime = 7200 - startlat = np.linspace(0, 80, npart) - startlon = 20.0 + np.zeros(npart) - pset = ParticleSet(fieldset, x=startlon, y=startlat) - pset.execute(AdvectionRK4, runtime=runtime, dt=np.timedelta64(15, "m")) - - assert fieldset.U.grid.deg2m == 1.0 # flat mesh deg2m - np.testing.assert_allclose(pset.x - startlon, runtime, atol=1e-5) - np.testing.assert_allclose(pset.y, startlat, atol=1e-5) - - -@pytest.mark.parametrize("bad_radius", ["6371000", [6371000], (1, 2), {}]) -def test_spherical_mesh_rejects_non_numeric_radius(bad_radius): - with pytest.raises(TypeError): - SphericalMesh(radius=bad_radius) - - -@pytest.mark.parametrize("bad_radius", [0, -1.0, -6371000]) -def test_spherical_mesh_rejects_nonpos_radius(bad_radius): - with pytest.raises(ValueError): - SphericalMesh(radius=bad_radius) diff --git a/tests/test_typing.py b/tests/test_typing.py index e69de29bb2..4f1d3e0c56 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -0,0 +1,20 @@ +import numpy as np +import pytest +import xarray as xr + +from parcels._typing import ( + assert_valid_mesh, +) + + +def test_invalid_assert_valid_mesh(): + with pytest.raises(ValueError, match="Invalid value"): + assert_valid_mesh("invalid option") + + ds = xr.Dataset({"A": (("a", "b"), np.arange(20).reshape(4, 5))}) + with pytest.raises(ValueError, match="Invalid input type"): + assert_valid_mesh(ds) + + +def test_assert_valid_mesh(): + assert_valid_mesh("spherical") diff --git a/tests/test_uxgrid.py b/tests/test_uxgrid.py index 63df6a3cae..5e3038cf3f 100644 --- a/tests/test_uxgrid.py +++ b/tests/test_uxgrid.py @@ -22,8 +22,7 @@ def test_uxgrid_axes(uxds): @pytest.mark.parametrize("mesh", ["flat", "spherical"]) def test_uxgrid_mesh(uxds, mesh): grid = UxGrid(uxds.uxgrid, z=uxds.coords["zf"], mesh=mesh) - - assert mesh in grid._mesh.__class__.__name__.lower() + assert grid._mesh == mesh @pytest.mark.parametrize("uxds", [uxdatasets["stommel_gyre_delaunay"]]) diff --git a/tests/test_xgrid.py b/tests/test_xgrid.py index d957ab0b96..2ba04e115c 100644 --- a/tests/test_xgrid.py +++ b/tests/test_xgrid.py @@ -72,7 +72,7 @@ def test_xgrid_axes(fieldset): @pytest.mark.parametrize("mesh", ["flat", "spherical"]) def test_uxgrid_mesh(ds, mesh): grid = FieldSet.from_sgrid_conventions(ds, mesh=mesh).data_g.grid - assert mesh in grid._mesh.__class__.__name__.lower() + assert grid._mesh == mesh @pytest.mark.skip(