This notebook contains a complete workflow of using OceanParcels simulations with SalishSeaCast and related model configurations.
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
%matplotlib inline
plt.rcParams['font.size'] = 12
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.). 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 and run_northsea_mp.py from the GitHub repo for Delandmeter and van Sebille 2019.
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 are used to prescribe particle behavior in OceanParcels.
For more context, see the OceanParcels Simple Tutorial and northsea_mp_kernels.py from the GitHub repo for Delandmeter and van Sebille 2019, Geosci. Model Dev.
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()
# 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))
# 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'])
--------------------------------------------------------------------------- KeyError Traceback (most recent call last) File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/file_manager.py:209, in CachingFileManager._acquire_with_cache_info(self, needs_lock) 208 try: --> 209 file = self._cache[self._key] 210 except KeyError: File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/lru_cache.py:55, in LRUCache.__getitem__(self, key) 54 with self._lock: ---> 55 value = self._cache[key] 56 self._cache.move_to_end(key) KeyError: [<class 'netCDF4._netCDF4.Dataset'>, ('/data/bmoorema/results/parcels/sandbox/GEM_grid.nc',), 'a', (('clobber', True), ('diskless', False), ('format', 'NETCDF4'), ('persist', False)), '69c58a63-25b9-48cb-9fc7-b8886971cbb8'] During handling of the above exception, another exception occurred: PermissionError Traceback (most recent call last) Cell In[13], line 6 4 # Append GEM and WW3 results into fieldset 5 for model in ['GEM', 'WW3']: ----> 6 append_auxiliary_forcing(fieldset, model, daterange, paths['results']) Cell In[10], line 73, in append_auxiliary_forcing(fieldset, model, daterange, coords_path) 71 ds = ds.drop_vars(data_vars) 72 ds.update({dimensions['lon']: ds[dimensions['lon']] - 360}) ---> 73 ds.to_netcdf(coords) 75 # Filenames dict 76 filenames = {'lon': coords, 'lat': coords, 'data': datafiles} File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/core/dataset.py:1912, in Dataset.to_netcdf(self, path, mode, format, group, engine, encoding, unlimited_dims, compute, invalid_netcdf) 1909 encoding = {} 1910 from xarray.backends.api import to_netcdf -> 1912 return to_netcdf( # type: ignore # mypy cannot resolve the overloads:( 1913 self, 1914 path, 1915 mode=mode, 1916 format=format, 1917 group=group, 1918 engine=engine, 1919 encoding=encoding, 1920 unlimited_dims=unlimited_dims, 1921 compute=compute, 1922 multifile=False, 1923 invalid_netcdf=invalid_netcdf, 1924 ) File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/api.py:1215, in to_netcdf(dataset, path_or_file, mode, format, group, engine, encoding, unlimited_dims, compute, multifile, invalid_netcdf) 1211 else: 1212 raise ValueError( 1213 f"unrecognized option 'invalid_netcdf' for engine {engine}" 1214 ) -> 1215 store = store_open(target, mode, format, group, **kwargs) 1217 if unlimited_dims is None: 1218 unlimited_dims = dataset.encoding.get("unlimited_dims", None) File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/netCDF4_.py:382, in NetCDF4DataStore.open(cls, filename, mode, format, group, clobber, diskless, persist, lock, lock_maker, autoclose) 376 kwargs = dict( 377 clobber=clobber, diskless=diskless, persist=persist, format=format 378 ) 379 manager = CachingFileManager( 380 netCDF4.Dataset, filename, mode=mode, kwargs=kwargs 381 ) --> 382 return cls(manager, group=group, mode=mode, lock=lock, autoclose=autoclose) File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/netCDF4_.py:329, in NetCDF4DataStore.__init__(self, manager, group, mode, lock, autoclose) 327 self._group = group 328 self._mode = mode --> 329 self.format = self.ds.data_model 330 self._filename = self.ds.filepath() 331 self.is_remote = is_remote_uri(self._filename) File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/netCDF4_.py:391, in NetCDF4DataStore.ds(self) 389 @property 390 def ds(self): --> 391 return self._acquire() File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/netCDF4_.py:385, in NetCDF4DataStore._acquire(self, needs_lock) 384 def _acquire(self, needs_lock=True): --> 385 with self._manager.acquire_context(needs_lock) as root: 386 ds = _nc4_require_group(root, self._group, self._mode) 387 return ds File ~/conda_envs/analysis-matt/lib/python3.10/contextlib.py:135, in _GeneratorContextManager.__enter__(self) 133 del self.args, self.kwds, self.func 134 try: --> 135 return next(self.gen) 136 except StopIteration: 137 raise RuntimeError("generator didn't yield") from None File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/file_manager.py:197, in CachingFileManager.acquire_context(self, needs_lock) 194 @contextlib.contextmanager 195 def acquire_context(self, needs_lock=True): 196 """Context manager for acquiring a file.""" --> 197 file, cached = self._acquire_with_cache_info(needs_lock) 198 try: 199 yield file File ~/conda_envs/analysis-matt/lib/python3.10/site-packages/xarray/backends/file_manager.py:215, in CachingFileManager._acquire_with_cache_info(self, needs_lock) 213 kwargs = kwargs.copy() 214 kwargs["mode"] = self._mode --> 215 file = self._opener(*self._args, **kwargs) 216 if self._mode == "w": 217 # ensure file doesn't get overridden when opened again 218 self._mode = "a" File src/netCDF4/_netCDF4.pyx:2463, in netCDF4._netCDF4.Dataset.__init__() File src/netCDF4/_netCDF4.pyx:2026, in netCDF4._netCDF4._ensure_nc_success() PermissionError: [Errno 13] Permission denied: b'/data/bmoorema/results/parcels/sandbox/GEM_grid.nc'
Run simulation with NEMO forcing only
# 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},
)
INFO: Compiled JITParticleAdvectionRK4 ==> /tmp/parcels-1896/33e0bbba1bc542d6e3b88a7d04aff565_0.so INFO: Temporary output files are stored in /data/bmoorema/results/parcels/sandbox/out-DMCPKVLI. 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. 100% |########################################################################|
Run simulation with NEMO+GEM+WW3 forcing
# 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},
)
INFO: Compiled JITParticleAdvectionRK4WindDriftStokesDrift ==> /tmp/parcels-1896/59971bb40696596c3f60fdc78dbd10d5_0.so INFO: Temporary output files are stored in /data/bmoorema/results/parcels/sandbox/out-DTKYIPOP. 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. 100% |########################################################################|
Compare results
%%capture
# Make initial figure
fig, axs = plt.subplots(1, 2, figsize=(15, 8), gridspec_kw={'wspace': 0.1})
l1, = axs[0].plot([], [], 'ko')
l2, = axs[1].plot([], [], 'ko')
t = axs[0].text(0.02, 0.02, '', transform=axs[0].transAxes)
data = {}
for ax, config in zip(axs, ['NEMO_2D', 'NEMOGEMWW3_2D']):
data[config] = xr.open_dataset(prefix + f'_{config}.nc')
ax.contourf(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='gray')
ax.contour(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='k')
ax.set_xlim([-124.3, -123])
ax.set_ylim([48.7, 49.6])
ax.set_title(config)
ax.set_aspect(1/np.sin(np.deg2rad(49)))
axs[1].yaxis.set_ticklabels('')
# Init function
def init():
t.set_text('')
for l in [l1, l2]:
l.set_data([], [])
return l1, l2, t,
# Animate function
def animate(hour):
tstamp = data['NEMO_2D'].time[0, hour].values.astype('datetime64[s]').astype(datetime)
t.set_text(tstamp.strftime('%Y-%b-%d %H:%M UTC'))
for l, config in zip([l1, l2], ['NEMO_2D', 'NEMOGEMWW3_2D']):
l.set_data(data[config].lon[:, hour], data[config].lat[:, hour])
return l1, l2, t,
# Build animation
anim = animation.FuncAnimation(fig, animate, init_func=init, frames=73, interval=100, blit=True)
# Render animation
HTML(anim.to_html5_video())
# Load SalishSeaCast results into fieldset
fieldset = fieldset_from_nemo(daterange, paths['coords'], flat=False)
Run simulation with NEMO forcing
# 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},
)
INFO: Compiled JITParticleAdvectionRK4_3D ==> /tmp/parcels-1896/64a6cf2f7a46cdc07fb84343d500440e_0.so INFO: Temporary output files are stored in /data/bmoorema/results/parcels/sandbox/out-RFUEEZNU. 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. 100% |########################################################################|
Visualize results
%%capture
# Make initial figure
fig, ax = plt.subplots(figsize=(8, 8))
cax = fig.add_axes([0.92, 0.15, 0.02, 0.7])
l = ax.scatter([], [], s=50, c=[], vmin=0, vmax=10, edgecolor='k')
t = ax.text(0.02, 0.02, '', transform=ax.transAxes)
data = xr.open_dataset(prefix + '_NEMO_3D.nc')
ax.contourf(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='gray')
ax.contour(gridlon, gridlat, tmask, levels=[-0.01, 0.01], colors='k')
ax.set_xlim([-124.3, -123])
ax.set_ylim([48.7, 49.6])
ax.set_title('NEMO_3D')
ax.set_aspect(1/np.sin(np.deg2rad(49)))
fig.colorbar(l, cax=cax, label='Depth [m]')
# Init function
def init():
t.set_text('')
l.set_offsets(np.empty((0, 2)))
l.set_array(np.empty(0))
return l, t,
# Animate function
def animate(hour):
tstamp = data.time[0, hour].values.astype('datetime64[s]').astype(datetime)
t.set_text(tstamp.strftime('%Y-%b-%d %H:%M UTC'))
l.set_offsets(np.vstack([data.lon[:, hour], data.lat[:, hour]]).T)
l.set_array(data.z[:, hour])
return l, t,
# Build animation
anim = animation.FuncAnimation(fig, animate, init_func=init, frames=73, interval=100, blit=True)
# Render animation
HTML(anim.to_html5_video())