#!/usr/bin/env python # coding: utf-8 # # OceanParcels recipes # # This notebook contains a complete workflow of using OceanParcels simulations with SalishSeaCast and related model configurations. # # *** # In[1]: import numpy as np import xarray as xr import os from matplotlib import pyplot as plt, animation from datetime import datetime, timedelta from dateutil.parser import parse from IPython.display import HTML from salishsea_tools import nc_tools, places from parcels import FieldSet, Field, VectorField, ParticleSet, JITParticle, ErrorCode, AdvectionRK4, AdvectionRK4_3D get_ipython().run_line_magic('matplotlib', 'inline') # In[2]: plt.rcParams['font.size'] = 12 # *** # # ### Functions # # #### Fieldset functions # # 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. # # 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. # In[10]: def fieldset_from_nemo(daterange, coords, flat=True): """Generate a fieldset from a hourly SalishSeaCast forcing fields over daterange. """ # Generate sequential list of forcing file prefixes prefixes = [ nc_tools.get_hindcast_prefix(daterange[0] + timedelta(days=d)) for d in range(np.diff(daterange)[0].days + 1) ] # Predefine fieldset argument dictionaries filenames, variables, dimensions = {}, {}, {} # Define dict fields for each variable for var, name in zip(['U', 'V', 'W'], ['vozocrtx', 'vomecrty', 'vovecrtz']): # Exclude vertical velocity if 2D if flat: if var == 'W': break # Dict of filenames containing the coordinate and forcing variables datafiles = [prefix + f'_grid_{var}.nc' for prefix in prefixes] filenames[var] = {'lon': coords, 'lat': coords, 'data': datafiles} # NEMO variable name variables[var] = name # Dict of NEMO coordinate names (f-points) dimensions[var] = {'lon': 'glamf', 'lat': 'gphif', 'time': 'time_counter'} # Add depth fields if 3D (f-points are on W grid) if not flat: filenames[var]['depth'] = prefixes[0] + '_grid_W.nc' dimensions[var]['depth'] = 'depthw' # Load NEMO forcing into fieldset field_set = FieldSet.from_nemo(filenames, variables, dimensions, allow_time_extrapolation=True) return field_set def append_auxiliary_forcing(fieldset, model, daterange, coords_path): """Append hourly GEM or WW3 forcing fields to a fieldset over daterange """ # Generate sequential list of forcing files datafiles = [ getattr(nc_tools, f'get_{model}_path')(daterange[0] + timedelta(days=d)) for d in range(np.diff(daterange)[0].days + 1) ] # Assign variable suffix and dimensions definitions according to model if model == 'GEM': suffix = '_wind' dimensions = {'lon': 'nav_lon', 'lat': 'nav_lat', 'time': 'time_counter'} elif model == 'WW3': suffix = 'uss' dimensions = {'lon': 'longitude', 'lat': 'latitude', 'time': 'time'} else: raise ValueError(f'Unknown surface forcing model: {model}') # Define coordinates file path and create if necessary # - only method I've had success with for correcting lons coords = os.path.join(coords_path, f'{model}_grid.nc') if not os.path.exists(coords): with xr.open_dataset(datafiles[0]) as ds: data_vars = [dimensions['time']] + list(ds.data_vars) if model == 'GEM': for key in ['lon', 'lat']: data_vars.remove(dimensions[key]) ds = ds.drop_vars(data_vars) ds.update({dimensions['lon']: ds[dimensions['lon']] - 360}) ds.to_netcdf(coords) # Filenames dict filenames = {'lon': coords, 'lat': coords, 'data': datafiles} # Load u velocity u = Field.from_netcdf( filenames, f'u{suffix}', dimensions, fieldtype='U', allow_time_extrapolation=True, ) # Load v velocity with u grid v = Field.from_netcdf( filenames, f'v{suffix}', dimensions, fieldtype='V', allow_time_extrapolation=True, grid=u.grid, dataFiles=u.dataFiles, ) # Add velocity fields to fieldset fieldset.add_field(u) fieldset.add_field(v) # Make new vector field from u and v for use in kernels u, v = [getattr(fieldset, f'{var}{suffix}') for var in ('u', 'v')] fieldset.add_vector_field(VectorField(f"UV_{model}", u, v)) # #### Kernel functions # # Kernel functions are used to prescribe particle behavior in OceanParcels. # # 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.* # In[11]: def WindDrift(particle, fieldset, time): (uw, vw) = fieldset.UV_GEM[time, particle.depth, particle.lat, particle.lon] particle.lon += uw * 0.03 * particle.dt particle.lat += vw * 0.03 * particle.dt def StokesDrift(particle, fieldset, time): (us, vs) = fieldset.UV_WW3[time, particle.depth, particle.lat, particle.lon] particle.lon += us * particle.dt particle.lat += vs * particle.dt def DeleteParticle(particle, fieldset, time): print(f'Particle {particle.id} lost !! [{particle.lon}, {particle.lat}, {particle.depth}, {particle.time}]') particle.delete() # *** # # ### Simulations # # Run parameters and definitions. # In[12]: # Paths and filenames paths = { 'coords': '/data/bmoorema/MEOPAR/grid/coordinates_seagrid_SalishSea201702.nc', 'mask': '/data/bmoorema/MEOPAR/grid/mesh_mask201702.nc', 'results': '/data/bmoorema/results/parcels/sandbox', } # Load coords and mask files and extract grid variables coords, mask = [xr.open_dataset(paths[key], decode_times=False) for key in ('coords', 'mask')] gridlon, gridlat = [coords[key][0, ...].values for key in ('glamt', 'gphit')] tmask = mask.tmask[0, 0, ...].values # Define release parameters location = 'Sandheads' n = 100 # number of particles r = 50 # radius of particle cloud [m] # Start time, duration and timestep start = datetime(2019, 1, 1, 12, 30, 0) duration = timedelta(days=3) dt = timedelta(seconds=90) # Create Gaussian distribution around release point mean, cov = [0, 0], [[r**2, 0], [0, r**2]] x_offset, y_offset = np.random.multivariate_normal(mean, cov, n).T lon, lat = places.PLACES[location]['lon lat'] lons = lon + x_offset / 111000 / np.cos(np.deg2rad(lat)) lats = lat + y_offset / 111000 # Forcing daterange (I add 1-day buffers) daterange = [start - timedelta(days=1), start + duration + timedelta(days=1)] # Output file prefix strings = [location] + [t.strftime('%Y%m%dT%H%M%S') for t in (start, start + duration)] prefix = os.path.join(paths['results'], '_'.join(strings)) # *** # # #### Surface drift simulations (2-D) # # Build forcing fieldset # In[13]: # Load SalishSeaCast results into fieldset fieldset = fieldset_from_nemo(daterange, paths['coords']) # Append GEM and WW3 results into fieldset for model in ['GEM', 'WW3']: append_auxiliary_forcing(fieldset, model, daterange, paths['results']) # Run simulation with NEMO forcing only # In[7]: # Execute NEMO-only, 2D run pset = ParticleSet.from_list(fieldset, JITParticle, lon=lons, lat=lats, time=np.repeat(start, n)) kernel = AdvectionRK4 output_file = pset.ParticleFile(name=prefix + '_NEMO_2D.nc', outputdt=timedelta(hours=1)) pset.execute( kernel, runtime=duration, dt=dt, output_file=output_file, recovery={ErrorCode.ErrorOutOfBounds: DeleteParticle}, ) # Run simulation with NEMO+GEM+WW3 forcing # In[8]: # Execute NEMO+GEM+WW3, 2D run pset = ParticleSet.from_list(fieldset, JITParticle, lon=lons, lat=lats, time=np.repeat(start, n)) kernel = AdvectionRK4 + pset.Kernel(WindDrift) + pset.Kernel(StokesDrift) output_file = pset.ParticleFile(name=prefix + '_NEMOGEMWW3_2D.nc', outputdt=timedelta(hours=1)) pset.execute( kernel, runtime=duration, dt=dt, output_file=output_file, recovery={ErrorCode.ErrorOutOfBounds: DeleteParticle}, ) # Compare results # In[10]: get_ipython().run_cell_magic('capture', '', "\n# Make initial figure\nfig, axs = plt.subplots(1, 2, figsize=(15, 8), gridspec_kw={'wspace': 0.1})\nl1, = axs[0].plot([], [], 'ko')\nl2, = axs[1].plot([], [], 'ko')\nt = axs[0].text(0.02, 0.02, '', transform=axs[0].transAxes)\ndata = {}\nfor 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)))\naxs[1].yaxis.set_ticklabels('')\n\n# Init function\ndef init():\n t.set_text('')\n for l in [l1, l2]:\n l.set_data([], [])\n return l1, l2, t,\n\n# Animate function\ndef 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\nanim = animation.FuncAnimation(fig, animate, init_func=init, frames=73, interval=100, blit=True)\n") # In[14]: # Render animation HTML(anim.to_html5_video()) # *** # # #### Particle simulations (3-D) # # Build forcing fieldset # In[12]: # Load SalishSeaCast results into fieldset fieldset = fieldset_from_nemo(daterange, paths['coords'], flat=False) # Run simulation with NEMO forcing # In[49]: # Execute NEMO-only, 3D run, release at 5m pset = ParticleSet.from_list(fieldset, JITParticle, lon=lons, lat=lats, depth=np.repeat(5, n), time=np.repeat(start, n)) kernel = AdvectionRK4_3D output_file = pset.ParticleFile(name=prefix + '_NEMO_3D.nc', outputdt=timedelta(hours=1)) pset.execute( kernel, runtime=duration, dt=dt, output_file=output_file, recovery={ErrorCode.ErrorOutOfBounds: DeleteParticle}, ) # Visualize results # In[8]: get_ipython().run_cell_magic('capture', '', "\n# Make initial figure\nfig, ax = plt.subplots(figsize=(8, 8))\ncax = fig.add_axes([0.92, 0.15, 0.02, 0.7])\nl = ax.scatter([], [], s=50, c=[], vmin=0, vmax=10, edgecolor='k')\nt = ax.text(0.02, 0.02, '', transform=ax.transAxes)\ndata = xr.open_dataset(prefix + '_NEMO_3D.nc')\nax.contourf(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='gray')\nax.contour(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='k')\nax.set_xlim([-124.3, -123])\nax.set_ylim([48.7, 49.6])\nax.set_title('NEMO_3D')\nax.set_aspect(1/np.sin(np.deg2rad(49)))\nfig.colorbar(l, cax=cax, label='Depth [m]')\n\n# Init function\ndef 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\ndef 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\nanim = animation.FuncAnimation(fig, animate, init_func=init, frames=73, interval=100, blit=True)\n") # In[9]: # Render animation HTML(anim.to_html5_video()) # In[ ]: