Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions docs/user_guide/examples/tutorial_delft3d.ipynb

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-This tutorial shows how to load in files from the [Delft3D](https://www.deltares.nl/en/software/delft3d-4-suite/) model into Parcels. 
+This tutorial shows how to load in [Delft3D](https://www.deltares.nl/en/software/delft3d-4-suite/) model output files (in NetCDF format) into Parcels.

if I recall correctly, NetCDF wasn't the default output format for Delft3D. Best to be explicit here I think

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! The default output for Delft3D is netcdf (it isn't for SWASH; see #2764). But your suggestion is good anyways

Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "0",
"metadata": {},
"source": [
"# 🖥️ Delft3D tutorial"
]
},
{
"cell_type": "markdown",
"id": "1",
"metadata": {},
"source": [
"This tutorial shows how to load in [Delft3D](https://www.deltares.nl/en/software/delft3d-4-suite/) model output files (in NetCDF format) into Parcels.\n",
"\n",
"## Structured Grids\n",
"Special about Delft3D is that its structured grid contains NaN values for points that don't exist in the model domain. The hashtable approach in Parcels v4 supports these NaN grid cells out of the box, so that you can use the model output directly without any preprocessing."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2",
"metadata": {},
"outputs": [],
"source": [
"import cmocean as cmo\n",
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"import parcels\n",
"import parcels.tutorial"
]
},
{
"cell_type": "markdown",
"id": "3",
"metadata": {},
"source": [
"The example below is of a small domain in the Port of Rotterdam. We first pick the coorindates and the velocity fields from the dataset, and then convert them to the sgrid conventions. Finally, we create a FieldSet from the converted dataset."
]
},
{
"cell_type": "markdown",
"id": "4",
"metadata": {},
"source": [
"```{note}\n",
"While Delft3D provides velocities on a CGrid, we currently can't use the `CGrid_Velocity` interpolator on the velocity fields. This is because the velocities have been rotated to east and north in the output - but the Parcels interpolator expects them in the `M` and `N` (along-grid) directions. Therefore, we use the `XFreeslip` interpolator instead.\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5",
"metadata": {},
"outputs": [],
"source": [
"ds = parcels.tutorial.open_dataset(\"Delft3D_data/Rotterdam_tiny\")\n",
"coords = ds[[\"XZETA\", \"YZETA\", \"SIGMA_C\"]]\n",
"ds_fset = parcels.convert.delft3d_to_sgrid(\n",
" fields={\"U\": ds[\"VELU\"], \"V\": ds[\"VELV\"]}, coords=coords\n",
")\n",
"fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)\n",
"\n",
"# Set the interpolation method for the UV field to XFreeslip (see note above)\n",
"fieldset.UV.interp_method = parcels.interpolators.XFreeslip()\n",
"\n",
"fieldset = fieldset.to_windowed_arrays()\n",
"fieldset.describe()"
]
},
{
"cell_type": "markdown",
"id": "6",
"metadata": {},
"source": [
"Now we define a grid of a few particles to release in the domain, and run a simple advection simulation. The particles are advected by the Delft3D velocity fields, and we can visualize their trajectories."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7",
"metadata": {},
"outputs": [],
"source": [
"# Define a set of starting points\n",
"points_x, points_y = np.meshgrid(\n",
" np.linspace(93000, 93300, 5), np.linspace(436300, 436600, 5)\n",
")\n",
"z = np.full(points_x.shape, 0.06) # Depth of 0.06 m\n",
"pset = parcels.ParticleSet(fieldset, x=points_x, y=points_y, z=z)\n",
"\n",
"# St up an output file to save particle trajectories\n",
"outputfile = parcels.ParticleFile(\n",
" \"Delft3D_structured.parquet\",\n",
" outputdt=np.timedelta64(60, \"s\"),\n",
" mode=\"w\",\n",
")\n",
"\n",
"\n",
"# Define a custom error handling kernel to delete particles on any error\n",
"def DeleteOnAnyError(particles, fieldset):\n",
" any_error = particles.state >= 50 # This captures all Errors\n",
" particles[any_error].state = parcels.StatusCode.Delete\n",
"\n",
"\n",
"# Run the particle set with the advection kernel and the custom error handling kernel\n",
"pset.execute(\n",
" [parcels.kernels.AdvectionRK2, DeleteOnAnyError],\n",
" endtime=fieldset.time_interval.right,\n",
" dt=np.timedelta64(60, \"s\"),\n",
" output_file=outputfile,\n",
" verbose_progress=False,\n",
")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8",
"metadata": {},
"outputs": [],
"source": [
"df = parcels.read_particlefile(\"Delft3D_structured.parquet\")\n",
"\n",
"fig, ax = plt.subplots(figsize=(8, 6))\n",
"\n",
"# Plot background velocity field\n",
"time_idx = 0\n",
"layer_idx = 0\n",
"speed = (\n",
" np.sqrt(ds[\"VELV\"] ** 2 + ds[\"VELU\"] ** 2)\n",
" .isel(TIME=time_idx, LAYER=layer_idx)\n",
" .compute()\n",
")\n",
"tpc = plt.pcolor(\n",
" speed.XZETA.values,\n",
" speed.YZETA.values,\n",
" speed,\n",
" edgecolors=\"black\",\n",
" cmap=\"cmo.speed\",\n",
" vmin=0,\n",
" vmax=np.max(np.abs(speed)),\n",
")\n",
"time_str = pd.to_datetime(speed.TIME.values).strftime(\"%Y-%m-%d %H:%M:%S\")\n",
"fig.colorbar(\n",
" tpc,\n",
" ax=ax,\n",
" label=f\"Flow speed on {time_str} and {speed.SIGMA_C.values:.2f}m depth [m/s]\",\n",
")\n",
"\n",
"for traj in df.partition_by(\"particle_id\"):\n",
" ax.plot(traj[\"x\"], traj[\"y\"], \"b\", alpha=0.8)\n",
"\n",
"ax.plot(points_x, points_y, \"m.\", label=\"initial particle positions\")\n",
"ax.set_aspect(\"equal\", adjustable=\"box\")\n",
"ax.set_xlabel(ds.XZETA.long_name)\n",
"ax.set_ylabel(ds.YZETA.long_name)\n",
"plt.show()"
]
}
],
"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": 5
}
1 change: 1 addition & 0 deletions docs/user_guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ examples/explanation_grids.md
examples/tutorial_nemo.ipynb
examples/tutorial_croco_3D.ipynb
examples/tutorial_mitgcm.ipynb
examples/tutorial_delft3d.ipynb
examples/tutorial_fesom.ipynb
examples/tutorial_schism.ipynb
examples/tutorial_velocityconversion.ipynb
Expand Down
1 change: 1 addition & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ trajan = "*"
matplotlib-base = ">=2.0.2"
gsw = "*"
py-triangle = "*"
cmocean = ">=4.0.3,<5"

[feature.devtools.dependencies]
pdbpp = "*"
Expand Down
3 changes: 2 additions & 1 deletion src/parcels/_core/xgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ def _transpose_xfield_data_to_tzyx(da: xr.DataArray, sgrid_metadata: sgrid.SGrid

# All dimensions must be associated with an axis in the grid
if set(dim_to_axis) != set(da.dims):
dims_not_on_grid = set(da.dims) - set(dim_to_axis)
raise ValueError(
f"DataArray {da.name!r} with dims {da.dims} has dimensions that are not associated with a direction on the provided grid."
f"DataArray {da.name!r} with dims {da.dims} has dimensions {dims_not_on_grid} that are not associated with a direction on the provided grid."
)

axes_not_in_field = set(_FIELD_DATA_ORDERING).difference(set(dim_to_axis.values()))
Expand Down
4 changes: 4 additions & 0 deletions src/parcels/_datasets/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def _get_data_home() -> Path:
# "data/DecayingMovingEddy_data/decaying_moving_eddyU.nc",
# "data/DecayingMovingEddy_data/decaying_moving_eddyV.nc",
# ]
+ [
"data/Delft3D_data/Rotterdam_tiny.nc",
]
+ [
"data/FESOM_periodic_channel/fesom_channel.nc",
"data/FESOM_periodic_channel/u.fesom_channel.nc",
Expand Down Expand Up @@ -221,6 +224,7 @@ class _Purpose(enum.Enum):
# ("Peninsula_data/T", (_V3Dataset(_ODIE,"data/Peninsula_data/peninsulaT.nc"), _Purpose.TUTORIAL)),
# ("GlobCurrent_example_data/data", (_V3Dataset(_ODIE,"data/GlobCurrent_example_data/*000000-GLOBCURRENT-L4-CUReul_hs-ALT_SUM-v02.0-fv01.0.nc", pre_decode_cf_callable=patch_dataset_v4_compat), _Purpose.TUTORIAL)),
("CopernicusMarine_data_for_Argo_tutorial/data", (_V3Dataset(_ODIE,"data/CopernicusMarine_data_for_Argo_tutorial/cmems_mod_glo_phy-*.nc"), _Purpose.TUTORIAL)),
("Delft3D_data/Rotterdam_tiny", (_V3Dataset(_ODIE,"data/Delft3D_data/Rotterdam_tiny.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)),
Expand Down
77 changes: 77 additions & 0 deletions src/parcels/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import enum
import typing
import warnings
from typing import cast

import numpy as np
Expand Down Expand Up @@ -101,6 +102,23 @@ class _Status(enum.Enum):
"T": "time",
}

_DELFT3D_X_EXPECTED_COORDS: list[tuple[str, _Status]] = [(name, _Status.REQUIRED) for name in ["XZETA", "YZETA"]]

_DELFT3D_X_VARNAMES_MAPPING: dict[str, str] = {
"XZETA": "lon",
"YZETA": "lat",
"SIGMA_C": "depth",
"TIME": "time",
}

_DELFT3D_X_AXIS_VARNAMES: dict[str, XgcmAxisDirection] = {
"M": "X",
"N": "Y",
"LAYER": "Z",
"time": "T",
}


_CROCO_EXPECTED_COORDS: list[tuple[str, _Status]] = [
(name, _Status.REQUIRED) for name in ["x_rho", "y_rho", "s_w", "time"]
]
Expand Down Expand Up @@ -568,6 +586,65 @@ def copernicusmarine_to_sgrid(
return ds


def delft3d_to_sgrid(*, fields: dict[str, xr.Dataset | xr.DataArray], coords: xr.Dataset) -> xr.Dataset:
"""Create an sgrid-compliant xarray.Dataset from a dataset of structured-grid Delft3D netcdf files.

Parameters
----------
fields : dict[str, xr.Dataset | xr.DataArray]
Dictionary of xarray.DataArray objects as obtained from a set of structured-grid Delft3D netcdf files.
coords : xarray.Dataset
xarray.Dataset containing coordinate variables.

Returns
-------
xarray.Dataset
Dataset object following SGRID conventions to be (optionally) modified and passed to a FieldSet constructor.

"""
warnings.warn(
"The delft3d_to_sgrid function is experimental and may not work for all Delft3D datasets. "
"Furthermore, we are not entirely confident that the SGrid layout for Delft3D is implemented correctly. "
"Please report any issues to the Parcels GitHub repository.",
UserWarning,
stacklevel=2,
)

fields = fields.copy()
coords = _pick_expected_coords(coords, _DELFT3D_X_EXPECTED_COORDS)

for name, field_da in fields.items():
if isinstance(field_da, xr.Dataset):
field_da = field_da[name]
# TODO: logging message, warn if multiple fields are in this dataset
else:
field_da = field_da.rename(name)
fields[name] = field_da

ds = xr.merge(list(fields.values()) + [coords], compat="override")

ds = _maybe_rename_variables(ds, _DELFT3D_X_VARNAMES_MAPPING)
ds = _set_coords(ds, _DELFT3D_X_AXIS_VARNAMES.keys())
ds = _set_axis_attrs(ds, _DELFT3D_X_AXIS_VARNAMES)

ds["grid"] = xr.DataArray(
0,
attrs=sgrid.SGrid2DMetadata(
cf_role="grid_topology",
topology_dimension=2,
node_dimensions=("M", "N"),
node_coordinates=("lon", "lat"),
face_dimensions=(
sgrid.FaceNodePadding("X", "M", sgrid.Padding.LOW),
sgrid.FaceNodePadding("Y", "N", sgrid.Padding.LOW),
),
vertical_dimensions=(sgrid.FaceNodePadding("Z", "LAYER", sgrid.Padding.HIGH),),
).to_attrs(),
)

return ds


# Known vertical dimension mappings by model
_FESOM2_VERTICAL_DIMS = {"interface": "nz", "center": "nz1"}
_ICON_VERTICAL_DIMS = {"interface": "depth_2", "center": "depth"}
Expand Down
10 changes: 10 additions & 0 deletions tests/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ def test_convert_copernicusmarine_no_currents(caplog):
assert caplog.text == ""


def test_convert_structured_delft3d():
ds = open_remote_dataset("Delft3D_data/Rotterdam_tiny")
coords = ds[["XZETA", "YZETA", "SIGMA_C"]]
ds_fset = convert.delft3d_to_sgrid(fields={"U": ds["VELU"], "V": ds["VELV"]}, coords=coords)
fieldset = FieldSet.from_sgrid_conventions(ds_fset)
assert "U" in fieldset.fields
assert "V" in fieldset.fields
assert "UV" in fieldset.fields


@pytest.mark.parametrize("ds", _COPERNICUS_DATASETS)
def test_convert_copernicusmarine_no_logs(ds, caplog):
ds = ds.copy()
Expand Down