{ "cells": [ { "cell_type": "markdown", "id": "eligible-induction", "metadata": {}, "source": [ "# OceanParcels recipes\n", "\n", "This notebook contains a complete workflow of using OceanParcels simulations with SalishSeaCast and related model configurations.\n", "\n", "***" ] }, { "cell_type": "code", "execution_count": 1, "id": "rocky-tradition", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import xarray as xr\n", "import os\n", "from matplotlib import pyplot as plt, animation\n", "from datetime import datetime, timedelta\n", "from dateutil.parser import parse\n", "from IPython.display import HTML\n", "from salishsea_tools import nc_tools, places\n", "\n", "from parcels import FieldSet, Field, VectorField, ParticleSet, JITParticle, ErrorCode, AdvectionRK4, AdvectionRK4_3D\n", "\n", "%matplotlib inline" ] }, { "cell_type": "code", "execution_count": 2, "id": "handy-occurrence", "metadata": {}, "outputs": [], "source": [ "plt.rcParams['font.size'] = 12" ] }, { "cell_type": "markdown", "id": "reduced-example", "metadata": {}, "source": [ "***\n", "\n", "### Functions\n", "\n", "#### Fieldset functions\n", "\n", "OceanParcels loads forcing data on-the-fly, and handles all of the interpolation. The NEMO C-grid interpolation is especially awesome (for more info see [Delandmeter and van Sebille 2019, *Geosci. Model Dev.*](https://gmd.copernicus.org/articles/12/3571/2019/gmd-12-3571-2019.html)). The following functions handle the syntax of defining the forcing files into a `parcels.FieldSet` object.\n", "\n", "For more context, see the OceanParcels [Nemo 3D Tutorial](https://nbviewer.jupyter.org/github/OceanParcels/parcels/blob/master/parcels/examples/tutorial_nemo_3D.ipynb) and [run_northsea_mp.py](https://github.com/OceanParcels/Parcelsv2.0PaperNorthSeaScripts/blob/master/run_northsea_mp.py) from the GitHub repo for Delandmeter and van Sebille 2019." ] }, { "cell_type": "code", "execution_count": 3, "id": "duplicate-chart", "metadata": {}, "outputs": [], "source": [ "def fieldset_from_nemo(daterange, coords, flat=True):\n", " \"\"\"Generate a fieldset from a hourly SalishSeaCast forcing fields\n", " over daterange.\n", " \"\"\"\n", "\n", " # Generate sequential list of forcing file prefixes\n", " prefixes = [\n", " nc_tools.get_hindcast_prefix(daterange[0] + timedelta(days=d))\n", " for d in range(np.diff(daterange)[0].days + 1)\n", " ]\n", "\n", " # Predefine fieldset argument dictionaries\n", " filenames, variables, dimensions = {}, {}, {}\n", "\n", " # Define dict fields for each variable\n", " for var, name in zip(['U', 'V', 'W'], ['vozocrtx', 'vomecrty', 'vovecrtz']):\n", " \n", " # Exclude vertical velocity if 2D\n", " if flat:\n", " if var == 'W': break\n", "\n", " # Dict of filenames containing the coordinate and forcing variables\n", " datafiles = [prefix + f'_grid_{var}.nc' for prefix in prefixes]\n", " filenames[var] = {'lon': coords, 'lat': coords, 'data': datafiles}\n", "\n", " # NEMO variable name\n", " variables[var] = name\n", "\n", " # Dict of NEMO coordinate names (f-points)\n", " dimensions[var] = {'lon': 'glamf', 'lat': 'gphif', 'time': 'time_counter'}\n", " \n", " # Add depth fields if 3D (f-points are on W grid)\n", " if not flat:\n", " filenames[var]['depth'] = prefixes[0] + '_grid_W.nc'\n", " dimensions[var]['depth'] = 'depthw'\n", "\n", " # Load NEMO forcing into fieldset\n", " field_set = FieldSet.from_nemo(filenames, variables, dimensions, field_chunksize='auto')\n", " \n", " return field_set\n", "\n", "\n", "def append_auxiliary_forcing(fieldset, model, daterange, coords_path):\n", " \"\"\"Append hourly GEM or WW3 forcing fields to a fieldset over daterange\n", " \"\"\"\n", "\n", " # Generate sequential list of forcing files\n", " datafiles = [\n", " getattr(nc_tools, f'get_{model}_path')(daterange[0] + timedelta(days=d))\n", " for d in range(np.diff(daterange)[0].days + 1)\n", " ]\n", " \n", " # Assign variable suffix and dimensions definitions according to model\n", " if model == 'GEM':\n", " suffix = '_wind'\n", " dimensions = {'lon': 'nav_lon', 'lat': 'nav_lat', 'time': 'time_counter'}\n", " elif model == 'WW3':\n", " suffix = 'uss'\n", " dimensions = {'lon': 'longitude', 'lat': 'latitude', 'time': 'time'}\n", " else:\n", " raise ValueError(f'Unknown surface forcing model: {model}')\n", " \n", " # Define coordinates file path and create if necessary\n", " # - only method I've had success with for correcting lons\n", " coords = os.path.join(coords_path, f'{model}_grid.nc')\n", " if not os.path.exists(coords):\n", " with xr.open_dataset(datafiles[0]) as ds:\n", " data_vars = [dimensions['time']] + list(ds.data_vars)\n", " if model == 'GEM':\n", " for key in ['lon', 'lat']: data_vars.remove(dimensions[key])\n", " ds = ds.drop_vars(data_vars)\n", " ds.update({dimensions['lon']: ds[dimensions['lon']] - 360})\n", " ds.to_netcdf(coords)\n", " \n", " # Filenames dict\n", " filenames = {'lon': coords, 'lat': coords, 'data': datafiles}\n", " \n", " # Load u velocity\n", " u = Field.from_netcdf(\n", " filenames, f'u{suffix}', dimensions, fieldtype='U', field_chunksize='auto',\n", " )\n", " \n", " # Load v velocity with u grid\n", " v = Field.from_netcdf(\n", " filenames, f'v{suffix}', dimensions, fieldtype='V', field_chunksize='auto',\n", " grid=u.grid, dataFiles=u.dataFiles,\n", " )\n", "\n", " # Add velocity fields to fieldset\n", " fieldset.add_field(u)\n", " fieldset.add_field(v)\n", "\n", " # Make new vector field from u and v for use in kernels\n", " u, v = [getattr(fieldset, f'{var}{suffix}') for var in ('u', 'v')]\n", " fieldset.add_vector_field(VectorField(f\"UV_{model}\", u, v))" ] }, { "cell_type": "markdown", "id": "together-music", "metadata": {}, "source": [ "#### Kernel functions\n", "\n", "Kernel functions are used to prescribe particle behavior in OceanParcels.\n", "\n", "For more context, see the OceanParcels [Simple Tutorial](https://nbviewer.jupyter.org/github/OceanParcels/parcels/blob/master/parcels/examples/parcels_tutorial.ipynb#Adding-a-custom-behaviour-kernel) and [northsea_mp_kernels.py](https://github.com/OceanParcels/Parcelsv2.0PaperNorthSeaScripts/blob/master/northsea_mp_kernels.py) from the GitHub repo for Delandmeter and van Sebille 2019, *Geosci. Model Dev.*" ] }, { "cell_type": "code", "execution_count": 4, "id": "received-sitting", "metadata": {}, "outputs": [], "source": [ "def WindDrift(particle, fieldset, time):\n", " (uw, vw) = fieldset.UV_GEM[time, particle.depth, particle.lat, particle.lon]\n", " particle.lon += uw * 0.03 * particle.dt\n", " particle.lat += vw * 0.03 * particle.dt\n", "\n", "\n", "def StokesDrift(particle, fieldset, time):\n", " (us, vs) = fieldset.UV_WW3[time, particle.depth, particle.lat, particle.lon]\n", " particle.lon += us * particle.dt\n", " particle.lat += vs * particle.dt\n", "\n", "\n", "def DeleteParticle(particle, fieldset, time):\n", " print(f'Particle {particle.id} lost !! [{particle.lon}, {particle.lat}, {particle.depth}, {particle.time}]')\n", " particle.delete()" ] }, { "cell_type": "markdown", "id": "wrong-creation", "metadata": {}, "source": [ "***\n", "\n", "### Simulations\n", "\n", "Run parameters and definitions." ] }, { "cell_type": "code", "execution_count": 5, "id": "imported-disaster", "metadata": {}, "outputs": [], "source": [ "# Paths and filenames\n", "paths = {\n", " 'coords': '/data/bmoorema/MEOPAR/grid/coordinates_seagrid_SalishSea201702.nc',\n", " 'mask': '/data/bmoorema/MEOPAR/grid/mesh_mask201702.nc',\n", " 'results': '/data/bmoorema/results/parcels/sandbox',\n", "}\n", "\n", "# Load coords and mask files and extract grid variables\n", "coords, mask = [xr.open_dataset(paths[key], decode_times=False) for key in ('coords', 'mask')]\n", "gridlon, gridlat = [coords[key][0, ...].values for key in ('glamt', 'gphit')]\n", "tmask = mask.tmask[0, 0, ...].values\n", "\n", "# Define release parameters\n", "location = 'Sandheads'\n", "n = 100 # number of particles\n", "r = 50 # radius of particle cloud [m]\n", "\n", "# Start time, duration and timestep\n", "start = datetime(2019, 1, 1, 12, 30, 0)\n", "duration = timedelta(days=3)\n", "dt = timedelta(seconds=90)\n", "\n", "# Create Gaussian distribution around release point\n", "mean, cov = [0, 0], [[r**2, 0], [0, r**2]]\n", "x_offset, y_offset = np.random.multivariate_normal(mean, cov, n).T\n", "lon, lat = places.PLACES[location]['lon lat']\n", "lons = lon + x_offset / 111000 / np.cos(np.deg2rad(lat))\n", "lats = lat + y_offset / 111000\n", "\n", "# Forcing daterange (I add 1-day buffers)\n", "daterange = [start - timedelta(days=1), start + duration + timedelta(days=1)]\n", "\n", "# Output file prefix\n", "strings = [location] + [t.strftime('%Y%m%dT%H%M%S') for t in (start, start + duration)]\n", "prefix = os.path.join(paths['results'], '_'.join(strings))" ] }, { "cell_type": "markdown", "id": "absent-riverside", "metadata": {}, "source": [ "***\n", "\n", "#### Surface drift simulations (2-D)\n", "\n", "Build forcing fieldset" ] }, { "cell_type": "code", "execution_count": 6, "id": "flexible-craft", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "WARNING: File /data/bmoorema/MEOPAR/grid/coordinates_seagrid_SalishSea201702.nc could not be decoded properly by xarray (version 0.16.2).\n", " It will be opened with no decoding. Filling values might be wrongly parsed.\n", "WARNING: Casting lon data to np.float32\n", "WARNING: Casting lat data to np.float32\n", "WARNING: Casting depth data to np.float32\n", "WARNING: Trying to initialize a shared grid with different chunking sizes - action prohibited. Replacing requested field_chunksize with grid's master chunksize.\n" ] } ], "source": [ "# Load SalishSeaCast results into fieldset\n", "fieldset = fieldset_from_nemo(daterange, paths['coords'])\n", "\n", "# Append GEM and WW3 results into fieldset\n", "for model in ['GEM', 'WW3']:\n", " append_auxiliary_forcing(fieldset, model, daterange, paths['results'])" ] }, { "cell_type": "markdown", "id": "surrounded-bouquet", "metadata": {}, "source": [ "Run simulation with NEMO forcing only" ] }, { "cell_type": "code", "execution_count": 7, "id": "relative-booth", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "INFO: Compiled JITParticleAdvectionRK4 ==> /tmp/parcels-1896/33e0bbba1bc542d6e3b88a7d04aff565_0.so\n", "INFO: Temporary output files are stored in /data/bmoorema/results/parcels/sandbox/out-DMCPKVLI.\n", "INFO: You can use \"parcels_convert_npydir_to_netcdf /data/bmoorema/results/parcels/sandbox/out-DMCPKVLI\" to convert these to a NetCDF file during the run.\n", "100% |########################################################################|\n" ] } ], "source": [ "# Execute NEMO-only, 2D run\n", "pset = ParticleSet.from_list(fieldset, JITParticle, lon=lons, lat=lats, time=np.repeat(start, n))\n", "kernel = AdvectionRK4\n", "output_file = pset.ParticleFile(name=prefix + '_NEMO_2D.nc', outputdt=timedelta(hours=1))\n", "pset.execute(\n", " kernel, runtime=duration, dt=dt, output_file=output_file,\n", " recovery={ErrorCode.ErrorOutOfBounds: DeleteParticle},\n", ")" ] }, { "cell_type": "markdown", "id": "helpful-montgomery", "metadata": {}, "source": [ "Run simulation with NEMO+GEM+WW3 forcing" ] }, { "cell_type": "code", "execution_count": 8, "id": "artistic-shift", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "INFO: Compiled JITParticleAdvectionRK4WindDriftStokesDrift ==> /tmp/parcels-1896/59971bb40696596c3f60fdc78dbd10d5_0.so\n", "INFO: Temporary output files are stored in /data/bmoorema/results/parcels/sandbox/out-DTKYIPOP.\n", "INFO: You can use \"parcels_convert_npydir_to_netcdf /data/bmoorema/results/parcels/sandbox/out-DTKYIPOP\" to convert these to a NetCDF file during the run.\n", "100% |########################################################################|\n" ] } ], "source": [ "# Execute NEMO+GEM+WW3, 2D run\n", "pset = ParticleSet.from_list(fieldset, JITParticle, lon=lons, lat=lats, time=np.repeat(start, n))\n", "kernel = AdvectionRK4 + pset.Kernel(WindDrift) + pset.Kernel(StokesDrift)\n", "output_file = pset.ParticleFile(name=prefix + '_NEMOGEMWW3_2D.nc', outputdt=timedelta(hours=1))\n", "pset.execute(\n", " kernel, runtime=duration, dt=dt, output_file=output_file,\n", " recovery={ErrorCode.ErrorOutOfBounds: DeleteParticle},\n", ")" ] }, { "cell_type": "markdown", "id": "dressed-lindsay", "metadata": {}, "source": [ "Compare results" ] }, { "cell_type": "code", "execution_count": 10, "id": "informed-indication", "metadata": {}, "outputs": [], "source": [ "%%capture\n", "\n", "# Make initial figure\n", "fig, axs = plt.subplots(1, 2, figsize=(15, 8), gridspec_kw={'wspace': 0.1})\n", "l1, = axs[0].plot([], [], 'ko')\n", "l2, = axs[1].plot([], [], 'ko')\n", "t = axs[0].text(0.02, 0.02, '', transform=axs[0].transAxes)\n", "data = {}\n", "for ax, config in zip(axs, ['NEMO_2D', 'NEMOGEMWW3_2D']):\n", " data[config] = xr.open_dataset(prefix + f'_{config}.nc')\n", " ax.contourf(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='gray')\n", " ax.contour(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='k')\n", " ax.set_xlim([-124.3, -123])\n", " ax.set_ylim([48.7, 49.6])\n", " ax.set_title(config)\n", " ax.set_aspect(1/np.sin(np.deg2rad(49)))\n", "axs[1].yaxis.set_ticklabels('')\n", "\n", "# Init function\n", "def init():\n", " t.set_text('')\n", " for l in [l1, l2]:\n", " l.set_data([], [])\n", " return l1, l2, t,\n", "\n", "# Animate function\n", "def animate(hour):\n", " tstamp = data['NEMO_2D'].time[0, hour].values.astype('datetime64[s]').astype(datetime)\n", " t.set_text(tstamp.strftime('%Y-%b-%d %H:%M UTC'))\n", " for l, config in zip([l1, l2], ['NEMO_2D', 'NEMOGEMWW3_2D']):\n", " l.set_data(data[config].lon[:, hour], data[config].lat[:, hour])\n", " return l1, l2, t,\n", "\n", "# Build animation\n", "anim = animation.FuncAnimation(fig, animate, init_func=init, frames=73, interval=100, blit=True)" ] }, { "cell_type": "code", "execution_count": 14, "id": "relevant-liechtenstein", "metadata": {}, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Render animation\n", "HTML(anim.to_html5_video())" ] }, { "cell_type": "markdown", "id": "every-federation", "metadata": {}, "source": [ "***\n", "\n", "#### Particle simulations (3-D)\n", "\n", "Build forcing fieldset" ] }, { "cell_type": "code", "execution_count": 12, "id": "arabic-austin", "metadata": {}, "outputs": [], "source": [ "# Load SalishSeaCast results into fieldset\n", "fieldset = fieldset_from_nemo(daterange, paths['coords'], flat=False)" ] }, { "cell_type": "markdown", "id": "abroad-condition", "metadata": {}, "source": [ "Run simulation with NEMO forcing" ] }, { "cell_type": "code", "execution_count": 49, "id": "surprising-mustang", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "INFO: Compiled JITParticleAdvectionRK4_3D ==> /tmp/parcels-1896/64a6cf2f7a46cdc07fb84343d500440e_0.so\n", "INFO: Temporary output files are stored in /data/bmoorema/results/parcels/sandbox/out-RFUEEZNU.\n", "INFO: You can use \"parcels_convert_npydir_to_netcdf /data/bmoorema/results/parcels/sandbox/out-RFUEEZNU\" to convert these to a NetCDF file during the run.\n", "100% |########################################################################|\n" ] } ], "source": [ "# Execute NEMO-only, 3D run, release at 5m\n", "pset = ParticleSet.from_list(fieldset, JITParticle, lon=lons, lat=lats, depth=np.repeat(5, n), time=np.repeat(start, n))\n", "kernel = AdvectionRK4_3D\n", "output_file = pset.ParticleFile(name=prefix + '_NEMO_3D.nc', outputdt=timedelta(hours=1))\n", "pset.execute(\n", " kernel, runtime=duration, dt=dt, output_file=output_file,\n", " recovery={ErrorCode.ErrorOutOfBounds: DeleteParticle},\n", ")" ] }, { "cell_type": "markdown", "id": "further-invention", "metadata": {}, "source": [ "Visualize results" ] }, { "cell_type": "code", "execution_count": 8, "id": "confident-growth", "metadata": {}, "outputs": [], "source": [ "%%capture\n", "\n", "# Make initial figure\n", "fig, ax = plt.subplots(figsize=(8, 8))\n", "cax = fig.add_axes([0.92, 0.15, 0.02, 0.7])\n", "l = ax.scatter([], [], s=50, c=[], vmin=0, vmax=10, edgecolor='k')\n", "t = ax.text(0.02, 0.02, '', transform=ax.transAxes)\n", "data = xr.open_dataset(prefix + '_NEMO_3D.nc')\n", "ax.contourf(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='gray')\n", "ax.contour(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='k')\n", "ax.set_xlim([-124.3, -123])\n", "ax.set_ylim([48.7, 49.6])\n", "ax.set_title('NEMO_3D')\n", "ax.set_aspect(1/np.sin(np.deg2rad(49)))\n", "fig.colorbar(l, cax=cax, label='Depth [m]')\n", "\n", "# Init function\n", "def init():\n", " t.set_text('')\n", " l.set_offsets(np.empty((0, 2)))\n", " l.set_array(np.empty(0))\n", " return l, t,\n", "\n", "# Animate function\n", "def animate(hour):\n", " tstamp = data.time[0, hour].values.astype('datetime64[s]').astype(datetime)\n", " t.set_text(tstamp.strftime('%Y-%b-%d %H:%M UTC'))\n", " l.set_offsets(np.vstack([data.lon[:, hour], data.lat[:, hour]]).T)\n", " l.set_array(data.z[:, hour])\n", " return l, t,\n", "\n", "# Build animation\n", "anim = animation.FuncAnimation(fig, animate, init_func=init, frames=73, interval=100, blit=True)" ] }, { "cell_type": "code", "execution_count": 9, "id": "worldwide-sally", "metadata": {}, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Render animation\n", "HTML(anim.to_html5_video())" ] }, { "cell_type": "code", "execution_count": null, "id": "fossil-nursing", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "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.8.6" } }, "nbformat": 4, "nbformat_minor": 5 }