Skip to content

Commit 36d73f0

Browse files
Merge pull request #2499 from Parcels-code/feature/ux_from_conventions
feature/ux_from_conventions
2 parents 89dc151 + 0d751cb commit 36d73f0

4 files changed

Lines changed: 207 additions & 85 deletions

File tree

src/parcels/_core/fieldset.py

Lines changed: 3 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ def gridset(self) -> list[BaseGrid]:
183183
@classmethod
184184
def from_ugrid_conventions(cls, ds: ux.UxDataset, mesh: str = "spherical"):
185185
"""Create a FieldSet from a Parcels compliant uxarray.UxDataset.
186+
187+
This is the primary ingestion method in Parcels for structured grid datasets.
188+
186189
The main requirements for a uxDataset are naming conventions for vertical grid dimensions & coordinates
187190
188191
zf - Name for coordinate and dimension for vertical positions at layer interfaces
@@ -225,63 +228,6 @@ def from_ugrid_conventions(cls, ds: ux.UxDataset, mesh: str = "spherical"):
225228

226229
return cls(list(fields.values()))
227230

228-
@classmethod
229-
def from_fesom2(cls, ds: ux.UxDataset, mesh: str = "spherical"):
230-
"""Create a FieldSet from a FESOM2 uxarray.UxDataset.
231-
232-
Parameters
233-
----------
234-
ds : uxarray.UxDataset
235-
uxarray.UxDataset as obtained from the uxarray package.
236-
237-
Returns
238-
-------
239-
FieldSet
240-
FieldSet object containing the fields from the dataset that can be used for a Parcels simulation.
241-
"""
242-
ds = ds.copy()
243-
ds_dims = list(ds.dims)
244-
if not all(dim in ds_dims for dim in ["time", "nz", "nz1"]):
245-
raise ValueError(
246-
f"Dataset missing one of the required dimensions 'time', 'nz', or 'nz1' for FESOM data. Found dimensions {ds_dims}"
247-
)
248-
ds = ds.rename(
249-
{
250-
"nz": "zf", # Vertical Interface
251-
"nz1": "zc", # Vertical Center
252-
}
253-
).set_index(zf="zf", zc="zc")
254-
255-
return FieldSet.from_ugrid_conventions(ds, mesh=mesh)
256-
257-
@classmethod
258-
def from_icon(cls, ds: ux.UxDataset, mesh: str = "spherical"):
259-
"""Create a FieldSet from a ICON uxarray.UxDataset.
260-
261-
Parameters
262-
----------
263-
ds : uxarray.UxDataset
264-
uxarray.UxDataset as obtained from the uxarray package.
265-
266-
Returns
267-
-------
268-
FieldSet
269-
FieldSet object containing the fields from the dataset that can be used for a Parcels simulation.
270-
"""
271-
ds = ds.copy()
272-
ds_dims = list(ds.dims)
273-
if not all(dim in ds_dims for dim in ["time", "depth", "depth_2"]):
274-
raise ValueError(
275-
f"Dataset missing one of the required dimensions 'time', 'depth', or 'depth_2' for ICON data. Found dimensions {ds_dims}"
276-
)
277-
ds = ds.rename(
278-
{
279-
"depth_2": "zf", # Vertical Interface
280-
"depth": "zc", # Vertical Center
281-
}
282-
).set_index(zf="zf", zc="zc")
283-
return FieldSet.from_ugrid_conventions(ds, mesh=mesh)
284-
285231
@classmethod
286232
def from_sgrid_conventions(
287233
cls, ds: xr.Dataset, mesh: Mesh | None = None

src/parcels/convert.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,3 +529,192 @@ def copernicusmarine_to_sgrid(
529529
)
530530

531531
return ds
532+
533+
534+
# Known vertical dimension mappings by model
535+
_FESOM2_VERTICAL_DIMS = {"interface": "nz", "center": "nz1"}
536+
_ICON_VERTICAL_DIMS = {"interface": "depth_2", "center": "depth"}
537+
538+
539+
def _detect_vertical_coordinates(
540+
ds: ux.UxDataset,
541+
known_mappings: dict[str, str] | None = None,
542+
) -> tuple[str, str]:
543+
"""Detect vertical coordinate dimensions for faces (zf) and centers (zc).
544+
545+
Detection strategy (with fallback):
546+
1. Use known_mappings if provided and dimensions exist
547+
2. Look for CF convention axis='Z' metadata
548+
3. Find dimension pairs where sizes differ by exactly 1
549+
550+
Parameters
551+
----------
552+
ds : ux.UxDataset
553+
UxDataset to analyze for vertical coordinates.
554+
known_mappings : dict[str, str] | None
555+
Optional mapping with keys "interface" and "center" specifying
556+
the dimension names for layer interfaces (zf) and centers (zc).
557+
558+
Returns
559+
-------
560+
tuple[str, str]
561+
Tuple of (interface_dim_name, center_dim_name).
562+
563+
Raises
564+
------
565+
ValueError
566+
If vertical coordinates cannot be detected.
567+
"""
568+
ds_dims = set(ds.dims)
569+
570+
# Strategy 1: Use known mappings if provided and dimensions exist
571+
if known_mappings is not None:
572+
interface_dim = known_mappings.get("interface")
573+
center_dim = known_mappings.get("center")
574+
if interface_dim in ds_dims and center_dim in ds_dims:
575+
logger.info(
576+
f"Using known vertical dimension mapping: {interface_dim!r} (interfaces) and {center_dim!r} (centers)."
577+
)
578+
return interface_dim, center_dim
579+
logger.debug(f"Known mappings {known_mappings} not found in dataset dimensions {ds_dims}. Trying CF metadata.")
580+
581+
# Strategy 2: Look for CF convention axis='Z' metadata
582+
z_dims = []
583+
for dim in ds_dims:
584+
if dim in ds.coords:
585+
coord = ds.coords[dim]
586+
if coord.attrs.get("axis") == "Z":
587+
z_dims.append(dim)
588+
elif coord.attrs.get("positive") in ("up", "down"):
589+
z_dims.append(dim)
590+
elif "depth" in coord.attrs.get("standard_name", "").lower():
591+
z_dims.append(dim)
592+
593+
if len(z_dims) == 2:
594+
# Sort by size - interface has n+1 values, center has n
595+
z_dims_sorted = sorted(z_dims, key=lambda d: ds.sizes[d], reverse=True)
596+
interface_dim, center_dim = z_dims_sorted
597+
if ds.sizes[interface_dim] == ds.sizes[center_dim] + 1:
598+
logger.info(
599+
f"Detected vertical dimensions from CF metadata: {interface_dim!r} (interfaces) and {center_dim!r} (centers)."
600+
)
601+
return interface_dim, center_dim
602+
603+
# Strategy 3: Find dimension pairs where sizes differ by exactly 1
604+
# Skip known non-vertical dimensions
605+
skip_dims = {"time", "n_face", "n_node", "n_edge", "n_max_face_nodes"}
606+
candidate_dims = [d for d in ds_dims if d not in skip_dims]
607+
608+
for dim1 in candidate_dims:
609+
for dim2 in candidate_dims:
610+
if dim1 != dim2:
611+
size1, size2 = ds.sizes[dim1], ds.sizes[dim2]
612+
if size1 == size2 + 1:
613+
logger.info(
614+
f"Auto-detected vertical dimensions by size difference: {dim1!r} (interfaces, size={size1}) "
615+
f"and {dim2!r} (centers, size={size2})."
616+
)
617+
return dim1, dim2
618+
619+
raise ValueError(
620+
f"Could not detect vertical coordinate dimensions in dataset with dims {list(ds_dims)}. "
621+
"Please ensure the dataset has vertical layer interface and center dimensions, "
622+
"or rename them manually to 'zf' (interfaces) and 'zc' (centers)."
623+
)
624+
625+
626+
def _rename_vertical_dims(
627+
ds: ux.UxDataset,
628+
interface_dim: str,
629+
center_dim: str,
630+
) -> ux.UxDataset:
631+
"""Rename vertical dimensions to zf (interfaces) and zc (centers).
632+
633+
Parameters
634+
----------
635+
ds : ux.UxDataset
636+
UxDataset with vertical dimensions to rename.
637+
interface_dim : str
638+
Current name of the interface dimension.
639+
center_dim : str
640+
Current name of the center dimension.
641+
642+
Returns
643+
-------
644+
ux.UxDataset
645+
Dataset with renamed dimensions and indexed coordinates.
646+
"""
647+
rename_dict = {}
648+
if interface_dim != "zf":
649+
rename_dict[interface_dim] = "zf"
650+
if center_dim != "zc":
651+
rename_dict[center_dim] = "zc"
652+
653+
if rename_dict:
654+
logger.info(f"Renaming vertical dimensions: {rename_dict}")
655+
ds = ds.rename(rename_dict)
656+
657+
ds = ds.set_index(zf="zf", zc="zc")
658+
return ds
659+
660+
661+
def fesom_to_ugrid(ds: ux.UxDataset) -> ux.UxDataset:
662+
"""Convert FESOM2 UxDataset to Parcels UGRID-compliant format.
663+
664+
Renames vertical dimensions:
665+
- nz -> zf (vertical layer faces/interfaces)
666+
- nz1 -> zc (vertical layer centers)
667+
668+
Parameters
669+
----------
670+
ds : ux.UxDataset
671+
FESOM2 UxDataset as obtained from uxarray.
672+
673+
Returns
674+
-------
675+
ux.UxDataset
676+
UGRID-compliant dataset ready for FieldSet.from_ugrid_conventions().
677+
678+
Examples
679+
--------
680+
>>> import uxarray as ux
681+
>>> from parcels import FieldSet
682+
>>> from parcels.convert import fesom_to_ugrid
683+
>>> ds = ux.open_mfdataset(grid_path, data_path)
684+
>>> ds_ugrid = fesom_to_ugrid(ds)
685+
>>> fieldset = FieldSet.from_ugrid_conventions(ds_ugrid, mesh="flat")
686+
"""
687+
ds = ds.copy()
688+
interface_dim, center_dim = _detect_vertical_coordinates(ds, _FESOM2_VERTICAL_DIMS)
689+
return _rename_vertical_dims(ds, interface_dim, center_dim)
690+
691+
692+
def icon_to_ugrid(ds: ux.UxDataset) -> ux.UxDataset:
693+
"""Convert ICON UxDataset to Parcels UGRID-compliant format.
694+
695+
Renames vertical dimensions:
696+
- depth_2 -> zf (vertical layer faces/interfaces)
697+
- depth -> zc (vertical layer centers)
698+
699+
Parameters
700+
----------
701+
ds : ux.UxDataset
702+
ICON UxDataset as obtained from uxarray.
703+
704+
Returns
705+
-------
706+
ux.UxDataset
707+
UGRID-compliant dataset ready for FieldSet.from_ugrid_conventions().
708+
709+
Examples
710+
--------
711+
>>> import uxarray as ux
712+
>>> from parcels import FieldSet
713+
>>> from parcels.convert import icon_to_ugrid
714+
>>> ds = ux.open_mfdataset(grid_path, data_path)
715+
>>> ds_ugrid = icon_to_ugrid(ds)
716+
>>> fieldset = FieldSet.from_ugrid_conventions(ds_ugrid, mesh="flat")
717+
"""
718+
ds = ds.copy()
719+
interface_dim, center_dim = _detect_vertical_coordinates(ds, _ICON_VERTICAL_DIMS)
720+
return _rename_vertical_dims(ds, interface_dim, center_dim)

tests/test_fieldset.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77
import xarray as xr
88

9-
from parcels import Field, ParticleFile, ParticleSet, VectorField, XGrid
9+
from parcels import Field, ParticleFile, ParticleSet, VectorField, XGrid, convert
1010
from parcels._core.fieldset import CalendarError, FieldSet, _datetime_to_msg
1111
from parcels._datasets.structured.generic import T as T_structured
1212
from parcels._datasets.structured.generic import datasets as datasets_structured
@@ -243,33 +243,33 @@ def test_fieldset_add_field_after_pset():
243243

244244

245245
def test_fieldset_from_icon():
246-
ds = datasets_unstructured["icon_square_delaunay_uniform_z_coordinate"]
247-
fieldset = FieldSet.from_icon(ds)
246+
ds = convert.icon_to_ugrid(datasets_unstructured["icon_square_delaunay_uniform_z_coordinate"])
247+
fieldset = FieldSet.from_ugrid_conventions(ds)
248248
assert "U" in fieldset.fields
249249
assert "V" in fieldset.fields
250250
assert "UVW" in fieldset.fields
251251

252252

253253
def test_fieldset_from_fesom2():
254-
ds = datasets_unstructured["fesom2_square_delaunay_uniform_z_coordinate"]
255-
fieldset = FieldSet.from_fesom2(ds)
254+
ds = convert.fesom_to_ugrid(datasets_unstructured["fesom2_square_delaunay_uniform_z_coordinate"])
255+
fieldset = FieldSet.from_ugrid_conventions(ds)
256256
assert "U" in fieldset.fields
257257
assert "V" in fieldset.fields
258258
assert "UVW" in fieldset.fields
259259

260260

261261
def test_fieldset_from_fesom2_missingUV():
262-
ds = datasets_unstructured["fesom2_square_delaunay_uniform_z_coordinate"]
262+
ds = convert.fesom_to_ugrid(datasets_unstructured["fesom2_square_delaunay_uniform_z_coordinate"])
263263
# Intentionally create a dataset that is missing the U field
264264
localds = ds.rename({"U": "notU"})
265265
with pytest.raises(ValueError) as info:
266-
_ = FieldSet.from_fesom2(localds)
266+
_ = FieldSet.from_ugrid_conventions(localds)
267267
assert "Dataset has only one of the two variables 'U' and 'V'" in str(info)
268268

269269
# Intentionally create a dataset that is missing the V field
270270
localds = ds.rename({"V": "notV"})
271271
with pytest.raises(ValueError) as info:
272-
_ = FieldSet.from_fesom2(localds)
272+
_ = FieldSet.from_ugrid_conventions(localds)
273273
assert "Dataset has only one of the two variables 'U' and 'V'" in str(info)
274274

275275

tests/test_uxarray_fieldset.py

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
download_example_dataset,
1313
)
1414
from parcels._datasets.unstructured.generic import datasets as datasets_unstructured
15+
from parcels.convert import fesom_to_ugrid, icon_to_ugrid
1516
from parcels.interpolators import (
1617
Ux_Velocity,
1718
UxConstantFaceConstantZC,
@@ -29,12 +30,7 @@ def ds_fesom_channel() -> ux.UxDataset:
2930
f"{fesom_path}/w.fesom_channel.nc",
3031
]
3132
ds = ux.open_mfdataset(grid_path, data_path).rename_vars({"u": "U", "v": "V", "w": "W"})
32-
ds = ds.rename(
33-
{
34-
"nz": "zf", # Vertical Interface
35-
"nz1": "zc", # Vertical Center
36-
}
37-
).set_index(zf="zf", zc="zc")
33+
ds = fesom_to_ugrid(ds)
3834
return ds
3935

4036

@@ -121,12 +117,7 @@ def test_fesom2_square_delaunay_uniform_z_coordinate_eval():
121117
Since the underlying data is constant, we can check that the values are as expected.
122118
"""
123119
ds = datasets_unstructured["fesom2_square_delaunay_uniform_z_coordinate"]
124-
ds = ds.rename(
125-
{
126-
"nz": "zf", # Vertical Interface
127-
"nz1": "zc", # Vertical Center
128-
}
129-
).set_index(zf="zf", zc="zc")
120+
ds = fesom_to_ugrid(ds)
130121
grid = UxGrid(ds.uxgrid, z=ds.coords["zf"], mesh="flat")
131122
UVW = VectorField(
132123
name="UVW",
@@ -174,12 +165,7 @@ def test_fesom2_square_delaunay_antimeridian_eval():
174165
Since the underlying data is constant, we can check that the values are as expected.
175166
"""
176167
ds = datasets_unstructured["fesom2_square_delaunay_antimeridian"]
177-
ds = ds.rename(
178-
{
179-
"nz": "zf", # Vertical Interface
180-
"nz1": "zc", # Vertical Center
181-
}
182-
).set_index(zf="zf", zc="zc")
168+
ds = fesom_to_ugrid(ds)
183169
P = Field(
184170
name="p",
185171
data=ds.p,
@@ -196,15 +182,16 @@ def test_fesom2_square_delaunay_antimeridian_eval():
196182

197183
def test_icon_evals():
198184
ds = datasets_unstructured["icon_square_delaunay_uniform_z_coordinate"].copy(deep=True)
199-
fieldset = FieldSet.from_icon(ds, mesh="flat")
185+
ds = icon_to_ugrid(ds)
186+
fieldset = FieldSet.from_ugrid_conventions(ds, mesh="flat")
200187

201188
# Query points, are chosen to be just a fraction off from the center of a cell for testing
202189
# This generic dataset has an effective lateral grid-spacing of 3 degrees and vertical grid
203190
# spacing of 100m - shifting by 1/10 of a degree laterally and 10m vertically should keep us
204191
# within the cell and make for easy exactness checking of constant and linear interpolation
205192
xc = ds.uxgrid.face_lon.values
206193
yc = ds.uxgrid.face_lat.values
207-
zc = 0.0 * xc + ds.depth.values[1] # Make zc the same length as xc
194+
zc = 0.0 * xc + ds.zc.values[1] # Make zc the same length as xc
208195

209196
tq = 0.0 * xc
210197
xq = xc + 0.1

0 commit comments

Comments
 (0)