π Writing your own convert function#
Parcels reads structured-grid model data through parcels.FieldSet.from_sgrid_conventions(). This function
does not guess how your model grid is laid out. Instead, it reads SGRID metadata
attached to the dataset, which provides information on which dimensions hold the cell corners, which dimensions hold the cell centres, and how
they line up. See the Grids explanation for more on where data can
sit on a grid cell.
Most model output does not (yet) come with SGrid-compliant metadata. For that reason, Parcels ships a set of built-in convert functions in
parcels.convert (for example parcels.convert.nemo_to_sgrid(),
parcels.convert.mitgcm_to_sgrid() and parcels.convert.croco_to_sgrid()). Each one uses what we know
about a particular model to rename variables and attach the SGRID metadata.
If your model is not covered, you can write your own convert function. This guide explains what Parcels expects,
walks through writing a converter for a made-up model, and shows how to check the result with describe().
Note
This guide covers structured grids only (SGRID). Unstructured grids use the UGRID conventions and are not covered here.
What a convert function needs to produce#
A convert function takes raw model output and returns a single xarray.Dataset that meets the
requirements below. The built-in converters take a fields dictionary (mapping the Parcels field name to a
DataArray or Dataset) and a coords dataset (holding the grid coordinates). We recommend that you use the same
signature.
Requirement |
Why Parcels needs it |
|---|---|
A variable (conventionally named |
This is where Parcels learns the grid layout. |
Every dimension of every data variable is either |
Parcels needs to know which axis (X, Y or Z) each dimension belongs to. |
For time-varying fields: has a |
Parcels uses it to interpolate in time and to check that the calendars of fields are compatible. |
The horizontal node (corner) coordinates are named |
Particles are located using these coordinates. Units containing |
The vertical node coordinate is named |
Parcels uses |
A |
Parcels uses it to interpolate in time and to check that the calendars of fields are compatible. |
Velocities are named |
Parcels auto-detects the |
SGRID in a nutshell#
SGRID describes a staggered grid with two kinds of points along each axis:
nodes are the cell corners.
lonandlatmust be defined here in Parcels.faces are the cell centres.
A note on the terminology used in this page. An axis is one of the physical directions of the model grid: X, Y and (for 3D data) Z. A
dimension is a named dimension of an array in the dataset (e.g. jt or jq). On a staggered grid,
variables sit at different positions along the same axis (e.g. Arakawa βFβ and βΟβ points align with the nodes, while Arakawa βTβ and βΟβ points align with the faces), so a single axis usually has more than one dimension.
SGRID keeps all fields and coordinates in a single dataset, so the dimensions along an axis must have distinct names.
For example, the Y-node dimension might be called jq and the Y-face dimension jt. Some models instead write
each field to a separate file and call both of these dimensions y, leaving it to the user to know where each field
sits on the grid. In that case, your convert function needs to rename the dimensions so that they are distinct.
On an Arakawa C-grid, the variables are placed as follows:
the zonal velocity
Usits on the X-node dimension and the Y-face dimension;the meridional velocity
Vsits on the X-face dimension and the Y-node dimension;tracers such as temperature sit on the face dimensions in both directions.
For each axis, SGRID pairs a face dimension with a node dimension and a padding. The padding says where the face
at index i lies relative to the node at index i:
face:node (padding:none)
βββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4
face:node (padding:low)
ββββββββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4 4
face:node (padding:high)
ββββββββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4 4
face:node (padding:both)
βββββββββββββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4 4 5
In these diagrams β is a node and βββββ is a face. The numbers are array indices. To choose the padding, start
by comparing the lengths of the face and node dimensions:
If there is one face fewer than nodes, every face lies between two nodes and there are no extra faces at the ends of the domain:
padding:none.If there is one face more than nodes, there is an extra face at both ends of the domain:
padding:both.If the lengths are equal, there is an extra face at one end of the domain only. Face
ithen lies either on the low side of nodei(padding:low) or on its high side (padding:high). The array sizes cannot tell you which of the two is correct, so you need to find this out from the model documentation.
The metadata is stored as string attributes on the grid variable:
Attribute |
Example value |
Meaning |
|---|---|---|
|
|
Marks this variable as the grid description. |
|
|
A 2D horizontal grid (vertical layers are handled below). |
|
|
The X and Y node dimensions, in that order. |
|
|
|
|
|
|
|
|
The variables holding the node longitude and latitude. |
Note
Parcels represents 3D data as a 2D SGRID grid (topology_dimension=2) plus vertical_dimensions. SGRID grids with topology_dimension=3 are not supported.
Worked example: the βToyOceanβ model#
Say we have output from a C-grid model called ToyOcean. Its documentation describes the grid as follows:
xq(iq),yq(jq): longitude and latitude of the south-west corner of each cell.xt(it),yt(jt): longitude and latitude of each cell centre.zw(kw): depth of the top of each layer.zt(kt): depth of each layerβs midpoint. Both are in metres, positive downward.uvel(t, kt, jt, iq): eastward velocity on the western face of each cell.vvel(t, kt, jq, it): northward velocity on the southern face of each cell.wvel(t, kw, jt, it): vertical velocity on the top face of each cell, positive upward.temp(t, kt, jt, it): temperature at the cell centre.
Weβll create some synthetic ToyOcean output with a uniform flow. A uniform flow lets us check the final particle position against the exact answer later on.
import numpy as np
import xarray as xr
import parcels
ni, nj, nk = 20, 15, 5
time = np.array(["2000-01-01", "2000-01-02", "2000-01-03"], dtype="datetime64[ns]")
nt = time.size
xq = np.arange(ni, dtype=float) # south-west corners
yq = 40.0 + np.arange(nj, dtype=float)
zw = np.linspace(0.0, 400.0, nk) # layer tops
coords = xr.Dataset(
{
"xq": ("iq", xq, {"units": "degrees_east"}),
"yq": ("jq", yq, {"units": "degrees_north"}),
"xt": ("it", xq + 0.5, {"units": "degrees_east"}),
"yt": ("jt", yq + 0.5, {"units": "degrees_north"}),
"zw": ("kw", zw, {"units": "m", "positive": "down"}),
"zt": ("kt", zw + 50.0, {"units": "m", "positive": "down"}),
}
)
def field(name, dims, value):
ds = xr.Dataset(
{name: (dims, np.full((nt, nk, nj, ni), value))},
coords={"t": time},
)
ds["t"].attrs['axis'] = 'T'
return ds
uvel = field("uvel", ("t", "kt", "jt", "iq"), 0.1) # 0.1 m/s eastward
vvel = field("vvel", ("t", "kt", "jq", "it"), 0.05) # 0.05 m/s northward
wvel = field("wvel", ("t", "kw", "jt", "it"), 1e-5) # 1e-5 m/s upward
temp = field("temp", ("t", "kt", "jt", "it"), 15.0)
Step 1: Map the model grid onto SGRID#
Before writing any code, fill in the face, node and padding for each axis:
Axis |
Face (centre) dim |
Node (corner) dim |
Padding |
Reasoning |
|---|---|---|---|---|
X |
|
|
|
Corner |
Y |
|
|
|
Corner |
Z |
|
|
|
Depth increases downward and |
Then check the velocity positions against this table. uvel uses iq (X node) and jt (Y face), and vvel uses
it (X face) and jq (Y node). That is exactly the C-grid layout described above.
Step 2: Write the convert function#
The function below follows the same steps as the built-in converters:
extract the fields and give them their Parcels names;
merge them with the node coordinates only;
rename the coordinates to what Parcels expects;
fix sign conventions;
attach the SGRID metadata.
def toyocean_to_sgrid(*, fields: dict[str, xr.Dataset | xr.DataArray], coords: xr.Dataset) -> xr.Dataset:
"""Create an SGRID-compliant xarray.Dataset from ToyOcean output."""
# 1. Pull each field out as a DataArray named after its Parcels field name
das = []
for name, field in fields.items():
da = field[next(iter(field.data_vars))] if isinstance(field, xr.Dataset) else field
das.append(da.rename(name))
# 2. Merge with only the node coordinates (Parcels doesn't need the cell-centre coordinates)
ds = xr.merge([*das, coords[["xq", "yq", "zw"]]])
ds.attrs.clear()
# 3. Rename to the names that Parcels expects
ds = ds.rename({"t": "time", "xq": "lon", "yq": "lat", "zw": "depth"})
ds = ds.set_coords(["lon", "lat", "depth"])
# 4. ToyOcean's W is positive upward but depth is positive downward; Parcels expects W to be in the direction of positive depth
if "W" in ds:
ds["W"] = -ds["W"]
# 5. Attach the SGRID metadata
ds["grid"] = xr.DataArray(
0, # The value here doesn't matter - so we put 0. The attributes are the important aspect
attrs={
"cf_role": "grid_topology",
"topology_dimension": 2,
"node_dimensions": "iq jq",
"face_dimensions": "it:iq (padding:high) jt:jq (padding:high)",
"vertical_dimensions": "kt:kw (padding:high)",
"node_coordinates": "lon lat",
},
)
return ds
ds = toyocean_to_sgrid(
fields={"U": uvel, "V": vvel, "W": wvel, "temperature": temp},
coords=coords,
)
ds
<xarray.Dataset> Size: 144kB
Dimensions: (time: 3, kt: 5, jt: 15, iq: 20, jq: 15, it: 20, kw: 5)
Coordinates:
* time (time) datetime64[ns] 24B 2000-01-01 2000-01-02 2000-01-03
lon (iq) float64 160B 0.0 1.0 2.0 3.0 4.0 ... 16.0 17.0 18.0 19.0
lat (jq) float64 120B 40.0 41.0 42.0 43.0 ... 51.0 52.0 53.0 54.0
depth (kw) float64 40B 0.0 100.0 200.0 300.0 400.0
Dimensions without coordinates: kt, jt, iq, jq, it, kw
Data variables:
U (time, kt, jt, iq) float64 36kB 0.1 0.1 0.1 0.1 ... 0.1 0.1 0.1
V (time, kt, jq, it) float64 36kB 0.05 0.05 0.05 ... 0.05 0.05
W (time, kw, jt, it) float64 36kB -1e-05 -1e-05 ... -1e-05 -1e-05
temperature (time, kt, jt, it) float64 36kB 15.0 15.0 15.0 ... 15.0 15.0
grid int64 8B 0Tip
If you need to rename a dimension after attaching the grid variable, use ds.sgrid.rename({...}) instead of
ds.rename({...}). It works the same way, but also updates the dimension names stored in the SGRID metadata.
Step 3: Check the grid with describe()#
The SGRID metadata can be read back through the ds.sgrid accessor. Calling describe() on it prints a summary of
the grid layout:
ds.sgrid.metadata.describe()
Grid2DMetadata
X-axis: face='it' node='iq' padding=high
Y-axis: face='jt' node='jq' padding=high
Z-axis: face='kt' node='kw' padding=high
Coordinates: lon, lat
Staggered grid layout (symbolic 3x3 nodes):
β Y β Z
| |
n --u-- n --u-- n w
| | | |
v Β· v Β· v Β·
| | | |
n --u-- n --u-- n w
| | | |
v Β· v Β· v Β·
| | | |
n --u-- n --u-- n --β X w
n = node (iq, jq)
u = x-face (it)
v = y-face (jt)
w = z-node (kw)
Β· = cell centre
Axis padding:
it:iq (padding:high)
ββββββββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4 4
jt:jq (padding:high)
ββββββββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4 4
kt:kw (padding:high)
ββββββββββββββββββββββββββββββ
0 0 1 1 2 2 3 3 4 4
Go through the output and compare it with your table from Step 1:
The axis lines (
X-axis,Y-axis,Z-axis) should pair each face dimension with the correct node dimension and padding. If X and Y are swapped here,node_dimensionsorface_dimensionsis in the wrong order.The staggered grid layout shows where
u(X-face) andv(Y-face) sit relative to the nodesn. Compare this withds["U"].dimsandds["V"].dims:Ushould use the X node dimension and the Y face dimension, andVthe other way round. On the vertical axis,wmarks the depth levels (depth) that sit on the node dimension.The axis padding diagrams show which side of node
ithe faceisits on. Compare them with the grid figure in your model documentation.
Important
The padding is the most important check, because Parcels cannot detect a wrong padding by itself. With low
instead of high, the dataset still loads without any error, but every field is shifted by half a grid cell.
Step 4: Create the FieldSet#
Once the metadata looks right, pass the dataset to parcels.FieldSet.from_sgrid_conventions(). Use
parcels.FieldSet.describe() to check that the vector fields were found and that the mesh is what you expect:
fieldset = parcels.FieldSet.from_sgrid_conventions(ds)
fieldset.describe()
| Name | Type | Grid number | Interp method / value | Parcels backend |
|:------------|:------------|--------------:|:------------------------|:------------------|
| U | Field | 0 | XLinear(...) | NumPy |
| V | Field | 0 | XLinear(...) | NumPy |
| W | Field | 0 | XLinear(...) | NumPy |
| temperature | Field | 0 | XLinear(...) | NumPy |
| UV | VectorField | 0 | CGrid_Velocity(...) | - |
| UVW | VectorField | 0 | CGrid_Velocity(...) | - |
mesh: SphericalMesh(radius=6366707.019493707)
time interval: (np.datetime64('2000-01-01T00:00:00.000000000'), np.datetime64('2000-01-03T00:00:00.000000000'))
Here we check that:
the
UVandUVWvector fields are listed. IfUVWis missing, the vertical velocity was not namedW;the velocity interpolator is
CGrid_Velocity. Parcels picks this whenUandVhave different dimensions, soXLinear_Velocitywould mean that Parcels treats the data as an A-grid;the mesh is
SphericalMesh, because thelonunits contain βdegreeβ.
Step 5: Check the result with a short run#
It is worth checking the new converter with a short run where you know the answer. With our uniform flow, one day
of advection should move a particle \(0.1 \times 86400\) m east, \(0.05 \times 86400\) m north, and \(10^{-5} \times 86400\) m
up. The last check also confirms that the sign of W was flipped correctly.
pset = parcels.ParticleSet(fieldset, x=[5.0], y=[45.0], z=[100.0])
pset.execute(
parcels.kernels.AdvectionRK4_3D,
runtime=np.timedelta64(1, "D"),
dt=np.timedelta64(1, "h"),
verbose_progress=False,
)
seconds = 86400
m_per_deg = 1852 * 60
expected_x = 5.0 + 0.1 * seconds / (m_per_deg * np.cos(np.deg2rad(45.0)))
expected_y = 45.0 + 0.05 * seconds / m_per_deg
expected_z = 100.0 - 1e-5 * seconds
print(f"x: {pset.x[0]:.4f} (expected ~{expected_x:.4f})")
print(f"y: {pset.y[0]:.4f} (expected ~{expected_y:.4f})")
print(f"z: {pset.z[0]:.4f} (expected ~{expected_z:.4f})")
np.testing.assert_allclose(pset.x[0], expected_x, atol=1e-3)
np.testing.assert_allclose(pset.y[0], expected_y, atol=1e-3)
np.testing.assert_allclose(pset.z[0], expected_z, atol=1e-3)
x: 5.1100 (expected ~5.1100)
y: 45.0389 (expected ~45.0389)
z: 99.1359 (expected ~99.1360)
Troubleshooting#
These are the most common problems when writing a converter, together with the error that each one causes:
Error message (abbreviated) |
Cause |
Fix |
|---|---|---|
|
No SGRID metadata has been attached. |
Add the |
|
A field dimension is not listed in the SGRID metadata. |
Add it to |
|
The node coordinates have not been renamed. |
Rename them to |
|
Parcels cannot tell whether the mesh is spherical or flat. |
Set |
|
Depth is stored as negative values, or ordered from the bottom up. |
Negate |
|
|
Decode it (e.g. with |
No error, but particles are shifted by half a cell |
Wrong padding. |
Compare the |
No error, but particles donβt move vertically, or move the wrong way |
Vertical velocity is not named |
Rename it to |
A template to start from#
def mymodel_to_sgrid(
*, fields: dict[str, xr.Dataset | xr.DataArray], coords: xr.Dataset
) -> xr.Dataset:
das = []
for name, field in fields.items():
da = (
field[next(iter(field.data_vars))]
if isinstance(field, xr.Dataset)
else field
)
das.append(da.rename(name))
ds = xr.merge(
[*das, coords[["<x_node_coord>", "<y_node_coord>", "<z_node_coord>"]]]
)
ds.attrs.clear()
ds = ds.rename(
{
"<time_dim>": "time",
"<x_node_coord>": "lon",
"<y_node_coord>": "lat",
"<z_node_coord>": "depth",
}
)
ds = ds.set_coords(["lon", "lat", "depth"])
# Model-specific fixes go here, e.g. sign of W, depth direction, time decoding, units
ds["grid"] = xr.DataArray(
0,
attrs={
"cf_role": "grid_topology",
"topology_dimension": 2,
"node_dimensions": "<x_node_dim> <y_node_dim>",
"face_dimensions": "<x_face_dim>:<x_node_dim> (padding:<p>) <y_face_dim>:<y_node_dim> (padding:<p>)",
"vertical_dimensions": "<z_face_dim>:<z_node_dim> (padding:<p>)",
"node_coordinates": "lon lat",
},
)
return ds
Then check the result:
ds = mymodel_to_sgrid(fields={...}, coords=...)
ds.sgrid.metadata.describe()
fieldset = parcels.FieldSet.from_sgrid_conventions(ds)
fieldset.describe()
Contributing your converter to Parcels#
If other people use your model too, please consider contributing your converter to Parcels. Inside
src/parcels/convert.py, converters build the metadata with Parcelsβ internal SGRID helpers rather than raw strings.
The helpers validate the structure when it is created:
import parcels._sgrid as sgrid
ds["grid"] = xr.DataArray(
0,
attrs=sgrid.SGrid2DMetadata(
cf_role="grid_topology",
topology_dimension=2,
node_dimensions=("iq", "jq"),
node_coordinates=("lon", "lat"),
face_dimensions=(
sgrid.FaceNodePadding("it", "iq", sgrid.Padding.HIGH),
sgrid.FaceNodePadding("jt", "jq", sgrid.Padding.HIGH),
),
vertical_dimensions=(sgrid.FaceNodePadding("kt", "kw", sgrid.Padding.HIGH),),
).to_attrs(),
)
A contribution would typically include the function in convert.py, a test in tests/test_convert.py, and a
tutorial notebook in the βConverting model data to FieldSetsβ section of this user guide. See the
development guide for how to get started.