From 94098f38f9e725f59f2c7292efd3a7656d93a573 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 16:21:39 +0200 Subject: [PATCH 1/8] Rename metadata objects --- src/parcels/_core/utils/sgrid.py | 36 +++++++++---------- src/parcels/_datasets/structured/generated.py | 4 +-- src/parcels/_datasets/structured/generic.py | 6 ++-- src/parcels/convert.py | 8 ++--- tests/strategies/sgrid.py | 8 ++--- tests/utils/test_sgrid.py | 30 ++++++++-------- 6 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/parcels/_core/utils/sgrid.py b/src/parcels/_core/utils/sgrid.py index 7b2e41cc7..6b0d566c8 100644 --- a/src/parcels/_core/utils/sgrid.py +++ b/src/parcels/_core/utils/sgrid.py @@ -56,7 +56,7 @@ def from_attrs(cls, d: dict[str, Hashable]) -> Self: ... # Note that - for some optional attributes in the SGRID spec - these IDs are not available # hence this isn't full coverage -_ID_FETCHERS_GRID2DMETADATA: dict[str, Callable[[Grid2DMetadata], Dim | Padding]] = { +_ID_FETCHERS_GRID2DMETADATA: dict[str, Callable[[SGrid2DMetadata], Dim | Padding]] = { "node_dimension1": lambda meta: meta.node_dimensions[0], "node_dimension2": lambda meta: meta.node_dimensions[1], "face_dimension1": lambda meta: meta.face_dimensions[0].face, @@ -65,7 +65,7 @@ def from_attrs(cls, d: dict[str, Hashable]) -> Self: ... "type2": lambda meta: meta.face_dimensions[1].padding, } -_ID_FETCHERS_GRID3DMETADATA: dict[str, Callable[[Grid3DMetadata], Dim | Padding]] = { +_ID_FETCHERS_GRID3DMETADATA: dict[str, Callable[[SGrid3DMetadata], Dim | Padding]] = { "node_dimension1": lambda meta: meta.node_dimensions[0], "node_dimension2": lambda meta: meta.node_dimensions[1], "node_dimension3": lambda meta: meta.node_dimensions[2], @@ -78,7 +78,7 @@ def from_attrs(cls, d: dict[str, Hashable]) -> Self: ... } -class Grid2DMetadata(AttrsSerializable): +class SGrid2DMetadata(AttrsSerializable): def __init__( self, cf_role: Literal["grid_topology"], @@ -152,7 +152,7 @@ def __str__(self) -> str: return _grid2d_to_ascii(self) def __eq__(self, other: Any) -> bool: - if not isinstance(other, Grid2DMetadata): + if not isinstance(other, SGrid2DMetadata): return NotImplemented return self.to_attrs() == other.to_attrs() @@ -200,7 +200,7 @@ def get_value_by_id(self, id: str) -> Dim | Padding: return _ID_FETCHERS_GRID2DMETADATA[id](self) -class Grid3DMetadata(AttrsSerializable): +class SGrid3DMetadata(AttrsSerializable): def __init__( self, cf_role: Literal["grid_topology"], @@ -268,7 +268,7 @@ def __str__(self) -> str: return _grid3d_to_ascii(self) def __eq__(self, other: Any) -> bool: - if not isinstance(other, Grid3DMetadata): + if not isinstance(other, SGrid3DMetadata): return NotImplemented return self.to_attrs() == other.to_attrs() @@ -431,14 +431,14 @@ class SGridParsingException(Exception): pass -def parse_grid_attrs(attrs: dict[str, Hashable]) -> Grid2DMetadata | Grid3DMetadata: - grid: Grid2DMetadata | Grid3DMetadata +def parse_grid_attrs(attrs: dict[str, Hashable]) -> SGrid2DMetadata | SGrid3DMetadata: + grid: SGrid2DMetadata | SGrid3DMetadata try: - grid = Grid2DMetadata.from_attrs(attrs) + grid = SGrid2DMetadata.from_attrs(attrs) except Exception as e: e.add_note("Failed to parse as 2D SGrid, trying 3D SGrid") try: - grid = Grid3DMetadata.from_attrs(attrs) + grid = SGrid3DMetadata.from_attrs(attrs) except Exception as e2: e2.add_note("Failed to parse as 3D SGrid") raise SGridParsingException("Failed to parse SGrid metadata as either 2D or 3D grid") from e2 @@ -464,10 +464,10 @@ def parse_sgrid(ds: xr.Dataset): except Exception as e: raise SGridParsingException(f"Error parsing {grid_topology=!r}") from e - if isinstance(grid, Grid2DMetadata): + if isinstance(grid, SGrid2DMetadata): dimensions = grid.face_dimensions + (grid.vertical_dimensions or ()) else: - assert isinstance(grid, Grid3DMetadata) + assert isinstance(grid, SGrid3DMetadata) dimensions = grid.volume_dimensions xgcm_coords = {} @@ -499,7 +499,7 @@ def rename(ds: xr.Dataset, name_dict: dict[str, str]) -> xr.Dataset: return ds -def get_unique_names(grid: Grid2DMetadata | Grid3DMetadata) -> set[str]: +def get_unique_names(grid: SGrid2DMetadata | SGrid3DMetadata) -> set[str]: dims = set() dims.update(set(grid.node_dimensions)) @@ -635,7 +635,7 @@ def _face_node_padding_to_text(obj: FaceNodePadding) -> list[str]: ยท = cell centre""" -def _grid2d_to_ascii(grid: Grid2DMetadata) -> str: +def _grid2d_to_ascii(grid: SGrid2DMetadata) -> str: fd = grid.face_dimensions nd = grid.node_dimensions lines = [ @@ -667,7 +667,7 @@ def _grid2d_to_ascii(grid: Grid2DMetadata) -> str: return "\n".join(lines) -def _grid3d_to_ascii(grid: Grid3DMetadata) -> str: +def _grid3d_to_ascii(grid: SGrid3DMetadata) -> str: vd = grid.volume_dimensions nd = grid.node_dimensions lines = [ @@ -694,7 +694,7 @@ def _grid3d_to_ascii(grid: Grid3DMetadata) -> str: return "\n".join(lines) -def _attach_sgrid_metadata(ds: xr.Dataset, grid: Grid2DMetadata | Grid3DMetadata): +def _attach_sgrid_metadata(ds: xr.Dataset, grid: SGrid2DMetadata | SGrid3DMetadata): """Copies the dataset and attaches the SGRID metadata in 'grid' variable. Modifies 'conventions' attribute.""" ds = ds.copy() ds["grid"] = ( @@ -707,11 +707,11 @@ def _attach_sgrid_metadata(ds: xr.Dataset, grid: Grid2DMetadata | Grid3DMetadata @overload -def _metadata_rename(grid: Grid2DMetadata, names_dict: dict[str, str]) -> Grid2DMetadata: ... +def _metadata_rename(grid: SGrid2DMetadata, names_dict: dict[str, str]) -> SGrid2DMetadata: ... @overload -def _metadata_rename(grid: Grid3DMetadata, names_dict: dict[str, str]) -> Grid3DMetadata: ... +def _metadata_rename(grid: SGrid3DMetadata, names_dict: dict[str, str]) -> SGrid3DMetadata: ... def _metadata_rename(grid, names_dict): diff --git a/src/parcels/_datasets/structured/generated.py b/src/parcels/_datasets/structured/generated.py index 8a4eaed98..5099a43ad 100644 --- a/src/parcels/_datasets/structured/generated.py +++ b/src/parcels/_datasets/structured/generated.py @@ -5,7 +5,7 @@ from parcels._core.utils.sgrid import ( FaceNodePadding, - Grid2DMetadata, + SGrid2DMetadata, Padding, _attach_sgrid_metadata, ) @@ -30,7 +30,7 @@ def simple_UV_dataset(dims=(360, 2, 30, 4), maxdepth=1, mesh="spherical"): }, ).pipe( _attach_sgrid_metadata, - Grid2DMetadata( + SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("XG", "YG"), diff --git a/src/parcels/_datasets/structured/generic.py b/src/parcels/_datasets/structured/generic.py index 3725e544b..5f3b4e26a 100644 --- a/src/parcels/_datasets/structured/generic.py +++ b/src/parcels/_datasets/structured/generic.py @@ -3,7 +3,7 @@ from parcels._core.utils.sgrid import ( FaceNodePadding, - Grid2DMetadata, + SGrid2DMetadata, Padding, _attach_sgrid_metadata, ) @@ -249,7 +249,7 @@ def _unrolled_cone_curvilinear_grid(): datasets["ds_2d_left"] .pipe( _attach_sgrid_metadata, - Grid2DMetadata( + SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("XG", "YG"), @@ -270,7 +270,7 @@ def _unrolled_cone_curvilinear_grid(): datasets["ds_2d_right"] .pipe( _attach_sgrid_metadata, - Grid2DMetadata( + SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("XG", "YG"), diff --git a/src/parcels/convert.py b/src/parcels/convert.py index bffe5c944..600262bd6 100644 --- a/src/parcels/convert.py +++ b/src/parcels/convert.py @@ -358,7 +358,7 @@ def nemo_to_sgrid(*, fields: dict[str, xr.Dataset | xr.DataArray], coords: xr.Da ds["grid"] = xr.DataArray( 0, - attrs=sgrid.Grid2DMetadata( + attrs=sgrid.SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("x", "y"), @@ -426,7 +426,7 @@ def mitgcm_to_sgrid(*, fields: dict[str, xr.Dataset | xr.DataArray], coords: xr. ds["grid"] = xr.DataArray( 0, - attrs=sgrid.Grid2DMetadata( + attrs=sgrid.SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("lon", "lat"), @@ -487,7 +487,7 @@ def croco_to_sgrid(*, fields: dict[str, xr.Dataset | xr.DataArray], coords: xr.D ds["grid"] = xr.DataArray( 0, - attrs=sgrid.Grid2DMetadata( + attrs=sgrid.SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("lon", "lat"), @@ -551,7 +551,7 @@ def copernicusmarine_to_sgrid( ) ds["grid"] = xr.DataArray( 0, - attrs=sgrid.Grid2DMetadata( # use dummy *_center dimensions - this is A grid data (all defined on nodes) + attrs=sgrid.SGrid2DMetadata( # use dummy *_center dimensions - this is A grid data (all defined on nodes) cf_role="grid_topology", topology_dimension=2, node_dimensions=("lon", "lat"), diff --git a/tests/strategies/sgrid.py b/tests/strategies/sgrid.py index a613d4e31..fcf067dd8 100644 --- a/tests/strategies/sgrid.py +++ b/tests/strategies/sgrid.py @@ -27,7 +27,7 @@ @st.composite -def grid2Dmetadata(draw) -> sgrid.Grid2DMetadata: +def grid2Dmetadata(draw) -> sgrid.SGrid2DMetadata: N = 8 names = draw( st.lists(dimension_name, min_size=N, max_size=N, unique=True) @@ -62,7 +62,7 @@ def grid2Dmetadata(draw) -> sgrid.Grid2DMetadata: else: vertical_dimensions = None - return sgrid.Grid2DMetadata( + return sgrid.SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=(node_dimension1, node_dimension2), @@ -76,7 +76,7 @@ def grid2Dmetadata(draw) -> sgrid.Grid2DMetadata: @st.composite -def grid3Dmetadata(draw) -> sgrid.Grid3DMetadata: +def grid3Dmetadata(draw) -> sgrid.SGrid3DMetadata: N = 9 names = draw( st.lists(dimension_name, min_size=N, max_size=N, unique=True) @@ -103,7 +103,7 @@ def grid3Dmetadata(draw) -> sgrid.Grid3DMetadata: else: node_coordinates = None - return sgrid.Grid3DMetadata( + return sgrid.SGrid3DMetadata( cf_role="grid_topology", topology_dimension=3, node_dimensions=(node_dimension1, node_dimension2, node_dimension3), diff --git a/tests/utils/test_sgrid.py b/tests/utils/test_sgrid.py index d48cb1253..f35e5f0d1 100644 --- a/tests/utils/test_sgrid.py +++ b/tests/utils/test_sgrid.py @@ -18,7 +18,7 @@ def create_example_grid2dmetadata(with_vertical_dimensions: bool, with_node_coor ) node_coordinates = ("node_coordinates_var1", "node_coordinates_var2") if with_node_coordinates else None - return sgrid.Grid2DMetadata( + return sgrid.SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("node_dimension1", "node_dimension2"), @@ -35,7 +35,7 @@ def create_example_grid3dmetadata(with_node_coordinates: bool): node_coordinates = ( ("node_coordinates_var1", "node_coordinates_var2", "node_coordinates_dim3") if with_node_coordinates else None ) - return sgrid.Grid3DMetadata( + return sgrid.SGrid3DMetadata( cf_role="grid_topology", topology_dimension=3, node_dimensions=("node_dimension1", "node_dimension2", "node_dimension3"), @@ -72,20 +72,20 @@ def create_example_grid3dmetadata(with_node_coordinates: bool): (grid3dmetadata, "type3", sgrid.Padding.LOW), ], ) -def test_get_value_by_id(sgrid_metadata: sgrid.Grid2DMetadata | sgrid.Grid3DMetadata, id, value): +def test_get_value_by_id(sgrid_metadata: sgrid.SGrid2DMetadata | sgrid.SGrid3DMetadata, id, value): assert sgrid_metadata.get_value_by_id(id) == value -def dummy_sgrid_ds(grid: sgrid.Grid2DMetadata | sgrid.Grid3DMetadata) -> xr.Dataset: - if isinstance(grid, sgrid.Grid2DMetadata): +def dummy_sgrid_ds(grid: sgrid.SGrid2DMetadata | sgrid.SGrid3DMetadata) -> xr.Dataset: + if isinstance(grid, sgrid.SGrid2DMetadata): return dummy_sgrid_2d_ds(grid) - elif isinstance(grid, sgrid.Grid3DMetadata): + elif isinstance(grid, sgrid.SGrid3DMetadata): return dummy_sgrid_3d_ds(grid) else: raise NotImplementedError(f"Cannot create dummy SGrid dataset for grid type {type(grid)}") -def dummy_sgrid_2d_ds(grid: sgrid.Grid2DMetadata) -> xr.Dataset: +def dummy_sgrid_2d_ds(grid: sgrid.SGrid2DMetadata) -> xr.Dataset: ds = dummy_comodo_3d_ds() # Can't rename dimensions that already exist in the dataset @@ -110,7 +110,7 @@ def dummy_sgrid_2d_ds(grid: sgrid.Grid2DMetadata) -> xr.Dataset: return ds -def dummy_sgrid_3d_ds(grid: sgrid.Grid3DMetadata) -> xr.Dataset: +def dummy_sgrid_3d_ds(grid: sgrid.SGrid3DMetadata) -> xr.Dataset: ds = dummy_comodo_3d_ds() # Can't rename dimensions that already exist in the dataset @@ -205,17 +205,17 @@ def test_load_dump_mappings(input_, expected): @example(grid2dmetadata) @given(pst.sgrid.grid2Dmetadata()) -def test_Grid2DMetadata_roundtrip(grid: sgrid.Grid2DMetadata): +def test_Grid2DMetadata_roundtrip(grid: sgrid.SGrid2DMetadata): attrs = grid.to_attrs() - parsed = sgrid.Grid2DMetadata.from_attrs(attrs) + parsed = sgrid.SGrid2DMetadata.from_attrs(attrs) assert parsed == grid @example(grid3dmetadata) @given(pst.sgrid.grid3Dmetadata()) -def test_Grid3DMetadata_roundtrip(grid: sgrid.Grid3DMetadata): +def test_Grid3DMetadata_roundtrip(grid: sgrid.SGrid3DMetadata): attrs = grid.to_attrs() - parsed = sgrid.Grid3DMetadata.from_attrs(attrs) + parsed = sgrid.SGrid3DMetadata.from_attrs(attrs) assert parsed == grid @@ -228,7 +228,7 @@ def test_parse_grid_attrs(grid: sgrid.AttrsSerializable): @example(grid2dmetadata) @given(pst.sgrid.grid2Dmetadata()) -def test_parse_sgrid_2d(grid_metadata: sgrid.Grid2DMetadata): +def test_parse_sgrid_2d(grid_metadata: sgrid.SGrid2DMetadata): """Test the ingestion of datasets in XGCM to ensure that it matches the SGRID metadata provided""" ds = dummy_sgrid_2d_ds(grid_metadata) @@ -250,7 +250,7 @@ def test_parse_sgrid_2d(grid_metadata: sgrid.Grid2DMetadata): @given(pst.sgrid.grid3Dmetadata()) -def test_parse_sgrid_3d(grid_metadata: sgrid.Grid3DMetadata): +def test_parse_sgrid_3d(grid_metadata: sgrid.SGrid3DMetadata): """Test the ingestion of datasets in XGCM to ensure that it matches the SGRID metadata provided""" ds = dummy_sgrid_3d_ds(grid_metadata) @@ -310,7 +310,7 @@ def test_rename_errors(): "grid": ( [], np.array(0), - sgrid.Grid2DMetadata( + sgrid.SGrid2DMetadata( cf_role="grid_topology", topology_dimension=2, node_dimensions=("XG", "YG"), From 44930a4af068f75a0c14ec8b7a92a6baa38d1170 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 16:22:38 +0200 Subject: [PATCH 2/8] Move strategies --- src/parcels/_datasets/structured/generated.py | 2 +- src/parcels/_datasets/structured/generic.py | 2 +- src/parcels/_strategies/__init__.py | 13 +++++++++++++ .../strategies => src/parcels/_strategies}/sgrid.py | 0 .../strategies => src/parcels/_strategies}/time.py | 0 tests/strategies/__init__.py | 3 --- tests/utils/test_sgrid.py | 2 +- tests/utils/test_time.py | 2 +- 8 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 src/parcels/_strategies/__init__.py rename {tests/strategies => src/parcels/_strategies}/sgrid.py (100%) rename {tests/strategies => src/parcels/_strategies}/time.py (100%) delete mode 100644 tests/strategies/__init__.py diff --git a/src/parcels/_datasets/structured/generated.py b/src/parcels/_datasets/structured/generated.py index 5099a43ad..c0477828b 100644 --- a/src/parcels/_datasets/structured/generated.py +++ b/src/parcels/_datasets/structured/generated.py @@ -5,8 +5,8 @@ from parcels._core.utils.sgrid import ( FaceNodePadding, - SGrid2DMetadata, Padding, + SGrid2DMetadata, _attach_sgrid_metadata, ) from parcels._core.utils.time import timedelta_to_float diff --git a/src/parcels/_datasets/structured/generic.py b/src/parcels/_datasets/structured/generic.py index 5f3b4e26a..c26d8e9ce 100644 --- a/src/parcels/_datasets/structured/generic.py +++ b/src/parcels/_datasets/structured/generic.py @@ -3,8 +3,8 @@ from parcels._core.utils.sgrid import ( FaceNodePadding, - SGrid2DMetadata, Padding, + SGrid2DMetadata, _attach_sgrid_metadata, ) from parcels._core.utils.sgrid import ( diff --git a/src/parcels/_strategies/__init__.py b/src/parcels/_strategies/__init__.py new file mode 100644 index 000000000..6cc66ee5a --- /dev/null +++ b/src/parcels/_strategies/__init__.py @@ -0,0 +1,13 @@ +# isort: skip_file + +try: + import hypothesis # noqa: F401 +except ImportError as err: + err.add_note( + "To use strategies you must have hypothesis installed. Install it from PyPI, Conda, or using your preffered package manager." + ) + raise err + +from . import sgrid, time + +__all__ = ["sgrid", "time"] diff --git a/tests/strategies/sgrid.py b/src/parcels/_strategies/sgrid.py similarity index 100% rename from tests/strategies/sgrid.py rename to src/parcels/_strategies/sgrid.py diff --git a/tests/strategies/time.py b/src/parcels/_strategies/time.py similarity index 100% rename from tests/strategies/time.py rename to src/parcels/_strategies/time.py diff --git a/tests/strategies/__init__.py b/tests/strategies/__init__.py deleted file mode 100644 index 5a8e17c88..000000000 --- a/tests/strategies/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import sgrid, time - -__all__ = ["sgrid", "time"] diff --git a/tests/utils/test_sgrid.py b/tests/utils/test_sgrid.py index f35e5f0d1..8d051e956 100644 --- a/tests/utils/test_sgrid.py +++ b/tests/utils/test_sgrid.py @@ -6,7 +6,7 @@ import xgcm from hypothesis import assume, example, given -import tests.strategies as pst +import parcels._strategies as pst from parcels._core.utils import sgrid diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 67d7a4f8e..3b1329414 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -7,7 +7,7 @@ from cftime import datetime as cftime_datetime from hypothesis import given -import tests.strategies as pst # parcels strategies +import parcels._strategies as pst # parcels strategies from parcels._core.utils.time import ( TimeInterval, _get_cf_attrs, From bb2564f2599dc69b5526ab872607348ca853a9df Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 16:27:20 +0200 Subject: [PATCH 3/8] Rename to face_node_padding --- src/parcels/_strategies/sgrid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parcels/_strategies/sgrid.py b/src/parcels/_strategies/sgrid.py index fcf067dd8..e94b7be5a 100644 --- a/src/parcels/_strategies/sgrid.py +++ b/src/parcels/_strategies/sgrid.py @@ -17,13 +17,13 @@ dimension_name = xr_st.names().filter( lambda s: " " not in s ) # assuming for now spaces are allowed in dimension names in SGrid convention -dim_dim_padding = ( +face_node_padding = ( st.tuples(dimension_name, dimension_name, padding) .filter(lambda t: t[0] != t[1]) .map(lambda t: sgrid.FaceNodePadding(*t)) ) -mappings = st.lists(dim_dim_padding | dimension_name).map(tuple) +mappings = st.lists(face_node_padding | dimension_name).map(tuple) @st.composite From c94d553b8205c9317c1f10246413f52955a6fca8 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 16:20:16 +0200 Subject: [PATCH 4/8] Add hypothesis strategy 'sgrid_dataset' for generating sgrid datasets --- .../_datasets/structured/strategies.py | 96 ++++++++++++++++ tests/datasets/test_strategies.py | 104 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/parcels/_datasets/structured/strategies.py create mode 100644 tests/datasets/test_strategies.py diff --git a/src/parcels/_datasets/structured/strategies.py b/src/parcels/_datasets/structured/strategies.py new file mode 100644 index 000000000..ec4714373 --- /dev/null +++ b/src/parcels/_datasets/structured/strategies.py @@ -0,0 +1,96 @@ +import numpy as np +import xarray as xr +from hypothesis import strategies as st +from hypothesis.extra.numpy import arrays as np_arrays + +import parcels._strategies as pst +from parcels._core.utils import sgrid +from parcels._core.utils.sgrid import _attach_sgrid_metadata + + +def _face_size(node_size: int, padding: sgrid.Padding) -> int: + if padding == sgrid.Padding.NONE: + return node_size - 1 + elif padding in (sgrid.Padding.LOW, sgrid.Padding.HIGH): + return node_size + else: # Padding.BOTH + return node_size + 1 + + +@st.composite +def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset: + """Strategy to create Xarray Sgrid datasets for testing""" + if grid is None: + grid = draw(pst.sgrid.grid2Dmetadata().filter(lambda g: g.node_coordinates is not None)) + elif grid.node_coordinates is None: + raise ValueError("grid must have node_coordinates set") + + N = draw(st.integers(min_value=5, max_value=100)) + M = draw(st.integers(min_value=5, max_value=100)) + + node_dim1, node_dim2 = grid.node_dimensions + face_dim1 = grid.face_dimensions[0].face + face_dim2 = grid.face_dimensions[1].face + N_face = _face_size(N, grid.face_dimensions[0].padding) + M_face = _face_size(M, grid.face_dimensions[1].padding) + + has_vertical = grid.vertical_dimensions is not None + if has_vertical: + P = draw(st.integers(min_value=5, max_value=20)) + vert_node_dim = grid.vertical_dimensions[0].node + vert_face_dim = grid.vertical_dimensions[0].face + P_face = _face_size(P, grid.vertical_dimensions[0].padding) + + has_curvilinear_grid = draw(st.booleans()) + coord_name1, coord_name2 = grid.node_coordinates + + if has_curvilinear_grid: + c1, c2 = np.meshgrid(np.linspace(0, 100, N), np.linspace(0, 100, M), indexing="ij") + coord1_dims = [node_dim1, node_dim2] + coord2_dims = [node_dim1, node_dim2] + else: + c1 = np.linspace(0, 100, N) + c2 = np.linspace(0, 100, M) + coord1_dims = [node_dim1] + coord2_dims = [node_dim2] + + num_fields = draw(st.integers(min_value=1, max_value=4)) + data_vars = {} + + for i in range(num_fields): + dim1 = draw(st.sampled_from([node_dim1, face_dim1])) + size1 = N if dim1 == node_dim1 else N_face + + dim2 = draw(st.sampled_from([node_dim2, face_dim2])) + size2 = M if dim2 == node_dim2 else M_face + + if has_vertical and draw(st.booleans()): + vert_dim = draw(st.sampled_from([vert_node_dim, vert_face_dim])) + vert_size = P if vert_dim == vert_node_dim else P_face + dims = [vert_dim, dim1, dim2] + shape = (vert_size, size1, size2) + else: + dims = [dim1, dim2] + shape = (size1, size2) + + data = draw( + np_arrays( + dtype=np.float64, + shape=shape, + elements=st.floats(min_value=1e-3, max_value=100.0, allow_nan=False, allow_infinity=False), + ) + ) + data_vars[f"field_{i}"] = (dims, data) + + coords = { + coord_name1: (coord1_dims, c1), + coord_name2: (coord2_dims, c2), + } + + ds = xr.Dataset(data_vars=data_vars, coords=coords) + return _attach_sgrid_metadata(ds, grid) + + +if __name__ == "__main__": + ex = sgrid_dataset().example() + breakpoint() diff --git a/tests/datasets/test_strategies.py b/tests/datasets/test_strategies.py new file mode 100644 index 000000000..8fc946d0e --- /dev/null +++ b/tests/datasets/test_strategies.py @@ -0,0 +1,104 @@ +import numpy as np +import pytest +import xarray as xr +from hypothesis import given, settings + +from parcels._core.utils import sgrid +from parcels._core.utils.sgrid import get_grid_topology, parse_grid_attrs +from parcels._datasets.structured.strategies import _face_size, sgrid_dataset + + +@pytest.mark.parametrize( + "n_nodes, padding, n_edges", + [ + (10, sgrid.Padding.NONE, 9), + (10, sgrid.Padding.LOW, 10), + (10, sgrid.Padding.HIGH, 10), + (10, sgrid.Padding.BOTH, 11), + ], +) +def test_face_size(n_nodes, padding, n_edges): + assert _face_size(n_nodes, padding) == n_edges + +def test_sgrid_dataset_raises_when_no_node_coordinates(): + no_coords_grid = sgrid.SGrid2DMetadata( + cf_role="grid_topology", + topology_dimension=2, + node_dimensions=("XG", "YG"), + face_dimensions=( + sgrid.FaceNodePadding("XC", "XG", sgrid.Padding.LOW), + sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.LOW), + ), + node_coordinates=None, + ) + with pytest.raises(ValueError, match="node_coordinates"): + sgrid_dataset(grid=no_coords_grid).example() + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_returns_dataset(ds): + assert isinstance(ds, xr.Dataset) + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_has_grid_topology(ds): + assert get_grid_topology(ds) is not None + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_node_coordinates_present(ds): + meta = parse_grid_attrs(get_grid_topology(ds).attrs) + assert meta.node_coordinates is not None + for coord_name in meta.node_coordinates: + assert coord_name in ds.coords + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_coordinate_shapes(ds): + meta = parse_grid_attrs(get_grid_topology(ds).attrs) + coord_name1, coord_name2 = meta.node_coordinates + node_dim1, node_dim2 = meta.node_dimensions + coord1 = ds.coords[coord_name1] + coord2 = ds.coords[coord_name2] + assert coord1.dims in [(node_dim1,), (node_dim1, node_dim2)] + assert coord2.dims in [(node_dim2,), (node_dim1, node_dim2)] + if len(coord1.dims) == 2: + assert len(coord2.dims) == 2 + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_has_at_least_one_field(ds): + non_grid_vars = [v for v in ds.data_vars if v != "grid"] + assert len(non_grid_vars) >= 1 + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_field_dims_are_valid(ds): + meta = parse_grid_attrs(get_grid_topology(ds).attrs) + valid_dims = set(meta.node_dimensions) + valid_dims.add(meta.face_dimensions[0].face) + valid_dims.add(meta.face_dimensions[1].face) + if meta.vertical_dimensions is not None: + valid_dims.add(meta.vertical_dimensions[0].node) + valid_dims.add(meta.vertical_dimensions[0].face) + + for var_name, var in ds.data_vars.items(): + if var_name == "grid": + continue + for dim in var.dims: + assert dim in valid_dims, f"Field {var_name!r} has unexpected dim {dim!r}" + + +@given(sgrid_dataset()) +@settings(max_examples=20) +def test_sgrid_dataset_no_nan_in_fields(ds): + for var_name, var in ds.data_vars.items(): + if var_name == "grid": + continue + assert not np.any(np.isnan(var.values)), f"NaN found in field {var_name!r}" From be785260a58b1ba48eba018f36dc640345c786f9 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 16:29:25 +0200 Subject: [PATCH 5/8] Allow SGRID metadata hypothesis strategies to use standard naming --- src/parcels/_strategies/sgrid.py | 50 ++++++++++++++++++++++--------- tests/datasets/test_strategies.py | 1 + tests/utils/test_sgrid.py | 22 ++++++++++++++ 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/parcels/_strategies/sgrid.py b/src/parcels/_strategies/sgrid.py index e94b7be5a..f413ab18b 100644 --- a/src/parcels/_strategies/sgrid.py +++ b/src/parcels/_strategies/sgrid.py @@ -27,13 +27,24 @@ @st.composite -def grid2Dmetadata(draw) -> sgrid.SGrid2DMetadata: - N = 8 - names = draw( - st.lists(dimension_name, min_size=N, max_size=N, unique=True) - # Reserved, as 'grid' name is used in Parcels testing to store grid information - .filter(lambda names: "grid" not in names) - ) +def grid2Dmetadata(draw, use_standard_names=False) -> sgrid.SGrid2DMetadata: + names = [ + "node_dimension1", + "node_dimension2", + "face_dimension1", + "face_dimension2", + "node_coordinates_var1", + "node_coordinates_var2", + "vertical_dimensions_face", + "vertical_dimensions_node", + ] + if not use_standard_names: + names = draw( + st.lists(dimension_name, min_size=len(names), max_size=len(names), unique=True) + # Reserved, as 'grid' name is used in Parcels testing to store grid information + .filter(lambda names: "grid" not in names) + ) + node_dimension1 = names[0] node_dimension2 = names[1] face_dimension1 = names[2] @@ -76,13 +87,24 @@ def grid2Dmetadata(draw) -> sgrid.SGrid2DMetadata: @st.composite -def grid3Dmetadata(draw) -> sgrid.SGrid3DMetadata: - N = 9 - names = draw( - st.lists(dimension_name, min_size=N, max_size=N, unique=True) - # Reserved, as 'grid' name is used in Parcels testing to store grid information - .filter(lambda names: "grid" not in names) - ) +def grid3Dmetadata(draw, use_standard_names=False) -> sgrid.SGrid3DMetadata: + names = [ + "node_dimension1", + "node_dimension2", + "node_dimension3", + "face_dimension1", + "face_dimension2", + "face_dimension3", + "node_coordinates_var1", + "node_coordinates_var2", + "node_coordinates_dim3", + ] + if not use_standard_names: + names = draw( + st.lists(dimension_name, min_size=len(names), max_size=len(names), unique=True) + # Reserved, as 'grid' name is used in Parcels testing to store grid information + .filter(lambda names: "grid" not in names) + ) node_dimension1 = names[0] node_dimension2 = names[1] node_dimension3 = names[2] diff --git a/tests/datasets/test_strategies.py b/tests/datasets/test_strategies.py index 8fc946d0e..6e2f2ea0f 100644 --- a/tests/datasets/test_strategies.py +++ b/tests/datasets/test_strategies.py @@ -20,6 +20,7 @@ def test_face_size(n_nodes, padding, n_edges): assert _face_size(n_nodes, padding) == n_edges + def test_sgrid_dataset_raises_when_no_node_coordinates(): no_coords_grid = sgrid.SGrid2DMetadata( cf_role="grid_topology", diff --git a/tests/utils/test_sgrid.py b/tests/utils/test_sgrid.py index 8d051e956..3b8292929 100644 --- a/tests/utils/test_sgrid.py +++ b/tests/utils/test_sgrid.py @@ -219,6 +219,28 @@ def test_Grid3DMetadata_roundtrip(grid: sgrid.SGrid3DMetadata): assert parsed == grid +@given(pst.sgrid.grid2Dmetadata(use_standard_names=True)) +def test_grid2Dmetadata_standard_names(grid: sgrid.SGrid2DMetadata): + assert grid.node_dimensions == ("node_dimension1", "node_dimension2") + assert grid.face_dimensions[0].face == "face_dimension1" + assert grid.face_dimensions[1].face == "face_dimension2" + if grid.node_coordinates is not None: + assert grid.node_coordinates == ("node_coordinates_var1", "node_coordinates_var2") + if grid.vertical_dimensions is not None: + assert grid.vertical_dimensions[0].face == "vertical_dimensions_face" + assert grid.vertical_dimensions[0].node == "vertical_dimensions_node" + + +@given(pst.sgrid.grid3Dmetadata(use_standard_names=True)) +def test_grid3Dmetadata_standard_names(grid: sgrid.SGrid3DMetadata): + assert grid.node_dimensions == ("node_dimension1", "node_dimension2", "node_dimension3") + assert grid.volume_dimensions[0].face == "face_dimension1" + assert grid.volume_dimensions[1].face == "face_dimension2" + assert grid.volume_dimensions[2].face == "face_dimension3" + if grid.node_coordinates is not None: + assert grid.node_coordinates == ("node_coordinates_var1", "node_coordinates_var2", "node_coordinates_dim3") + + @given(pst.sgrid.grid_metadata) def test_parse_grid_attrs(grid: sgrid.AttrsSerializable): attrs = grid.to_attrs() From 01dd1b6de44b863a3868783f9515aba24832e6ba Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 17:00:43 +0200 Subject: [PATCH 6/8] Update sgrid_dataset strategy to use standard names --- src/parcels/_datasets/structured/strategies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_datasets/structured/strategies.py b/src/parcels/_datasets/structured/strategies.py index ec4714373..a67308b43 100644 --- a/src/parcels/_datasets/structured/strategies.py +++ b/src/parcels/_datasets/structured/strategies.py @@ -21,7 +21,7 @@ def _face_size(node_size: int, padding: sgrid.Padding) -> int: def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset: """Strategy to create Xarray Sgrid datasets for testing""" if grid is None: - grid = draw(pst.sgrid.grid2Dmetadata().filter(lambda g: g.node_coordinates is not None)) + grid = draw(pst.sgrid.grid2Dmetadata(use_standard_names=True).filter(lambda g: g.node_coordinates is not None)) elif grid.node_coordinates is None: raise ValueError("grid must have node_coordinates set") From b7883ef776373916767487405ec9d8d25f6c3c02 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 13 May 2026 17:03:19 +0200 Subject: [PATCH 7/8] Silence NonInteractiveExampleWarning --- .../_datasets/structured/strategies.py | 7 +---- tests/datasets/test_strategies.py | 29 +++++++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/parcels/_datasets/structured/strategies.py b/src/parcels/_datasets/structured/strategies.py index a67308b43..ccb10267d 100644 --- a/src/parcels/_datasets/structured/strategies.py +++ b/src/parcels/_datasets/structured/strategies.py @@ -23,7 +23,7 @@ def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset if grid is None: grid = draw(pst.sgrid.grid2Dmetadata(use_standard_names=True).filter(lambda g: g.node_coordinates is not None)) elif grid.node_coordinates is None: - raise ValueError("grid must have node_coordinates set") + raise ValueError("grid in Parcels must have node_coordinates set") N = draw(st.integers(min_value=5, max_value=100)) M = draw(st.integers(min_value=5, max_value=100)) @@ -89,8 +89,3 @@ def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset ds = xr.Dataset(data_vars=data_vars, coords=coords) return _attach_sgrid_metadata(ds, grid) - - -if __name__ == "__main__": - ex = sgrid_dataset().example() - breakpoint() diff --git a/tests/datasets/test_strategies.py b/tests/datasets/test_strategies.py index 6e2f2ea0f..38083cb54 100644 --- a/tests/datasets/test_strategies.py +++ b/tests/datasets/test_strategies.py @@ -1,7 +1,10 @@ +import warnings + import numpy as np import pytest import xarray as xr from hypothesis import given, settings +from hypothesis.errors import NonInteractiveExampleWarning from parcels._core.utils import sgrid from parcels._core.utils.sgrid import get_grid_topology, parse_grid_attrs @@ -22,18 +25,20 @@ def test_face_size(n_nodes, padding, n_edges): def test_sgrid_dataset_raises_when_no_node_coordinates(): - no_coords_grid = sgrid.SGrid2DMetadata( - cf_role="grid_topology", - topology_dimension=2, - node_dimensions=("XG", "YG"), - face_dimensions=( - sgrid.FaceNodePadding("XC", "XG", sgrid.Padding.LOW), - sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.LOW), - ), - node_coordinates=None, - ) - with pytest.raises(ValueError, match="node_coordinates"): - sgrid_dataset(grid=no_coords_grid).example() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=NonInteractiveExampleWarning) + no_coords_grid = sgrid.SGrid2DMetadata( + cf_role="grid_topology", + topology_dimension=2, + node_dimensions=("XG", "YG"), + face_dimensions=( + sgrid.FaceNodePadding("XC", "XG", sgrid.Padding.LOW), + sgrid.FaceNodePadding("YC", "YG", sgrid.Padding.LOW), + ), + node_coordinates=None, + ) + with pytest.raises(ValueError, match="node_coordinates"): + sgrid_dataset(grid=no_coords_grid).example() @given(sgrid_dataset()) From 0c04dd65be2ded86950c7c8111bc304046947743 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Mon, 18 May 2026 13:51:02 +0200 Subject: [PATCH 8/8] Fix typing --- src/parcels/_core/utils/sgrid.py | 2 +- src/parcels/_datasets/structured/strategies.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/parcels/_core/utils/sgrid.py b/src/parcels/_core/utils/sgrid.py index 6b0d566c8..ed8edce60 100644 --- a/src/parcels/_core/utils/sgrid.py +++ b/src/parcels/_core/utils/sgrid.py @@ -694,7 +694,7 @@ def _grid3d_to_ascii(grid: SGrid3DMetadata) -> str: return "\n".join(lines) -def _attach_sgrid_metadata(ds: xr.Dataset, grid: SGrid2DMetadata | SGrid3DMetadata): +def _attach_sgrid_metadata(ds: xr.Dataset, grid: SGrid2DMetadata | SGrid3DMetadata) -> xr.Dataset: """Copies the dataset and attaches the SGRID metadata in 'grid' variable. Modifies 'conventions' attribute.""" ds = ds.copy() ds["grid"] = ( diff --git a/src/parcels/_datasets/structured/strategies.py b/src/parcels/_datasets/structured/strategies.py index ccb10267d..c66dd0916 100644 --- a/src/parcels/_datasets/structured/strategies.py +++ b/src/parcels/_datasets/structured/strategies.py @@ -24,6 +24,8 @@ def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset grid = draw(pst.sgrid.grid2Dmetadata(use_standard_names=True).filter(lambda g: g.node_coordinates is not None)) elif grid.node_coordinates is None: raise ValueError("grid in Parcels must have node_coordinates set") + assert grid is not None + assert grid.node_coordinates is not None N = draw(st.integers(min_value=5, max_value=100)) M = draw(st.integers(min_value=5, max_value=100)) @@ -34,8 +36,7 @@ def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset N_face = _face_size(N, grid.face_dimensions[0].padding) M_face = _face_size(M, grid.face_dimensions[1].padding) - has_vertical = grid.vertical_dimensions is not None - if has_vertical: + if has_vertical := grid.vertical_dimensions is not None: P = draw(st.integers(min_value=5, max_value=20)) vert_node_dim = grid.vertical_dimensions[0].node vert_face_dim = grid.vertical_dimensions[0].face @@ -64,6 +65,7 @@ def sgrid_dataset(draw, grid: sgrid.SGrid2DMetadata | None = None) -> xr.Dataset dim2 = draw(st.sampled_from([node_dim2, face_dim2])) size2 = M if dim2 == node_dim2 else M_face + shape: tuple[int, ...] if has_vertical and draw(st.booleans()): vert_dim = draw(st.sampled_from([vert_node_dim, vert_face_dim])) vert_size = P if vert_dim == vert_node_dim else P_face