Skip to content

Commit b8c07a1

Browse files
rajeejaerogluorhan
andauthored
Add open_multigrid list_grid_names functions (#1404)
* o Add open_multigrid/list_grid_names API with initial OASIS multi-grid SCRIP support (https://cerfacs.fr/oa4web/oasis3-mct_5.0/oasis3mct_UserGuide/node50.html) * o Use pytest style * Fix assumption that center_lon is a NumPy array * o Handle stacking/masking by flattening multi-dimensional cell axes before building grids * o Try to fix mask errors reported by Nils for OASIS format. * o Add test for open_multigrid_mask_zero_faces * Add OASIS mask fixture for multigrid mask test * o customizable mask values in the open_multigrid function and tests * o edge case for when a mask zeros out all faces * o Keep SCRIP grid center calc lazy, use where construct instead of asarray --------- Co-authored-by: Orhan Eroglu <[email protected]>
1 parent 369a24b commit b8c07a1

File tree

13 files changed

+867
-15
lines changed

13 files changed

+867
-15
lines changed

docs/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Top Level Functions
1818

1919
open_grid
2020
open_dataset
21+
open_multigrid
2122
open_mfdataset
2223
concat
2324

test/core/test_api.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
import pytest
55
import tempfile
66
import xarray as xr
7+
from pathlib import Path
78
from unittest.mock import patch
89
from uxarray.core.utils import _open_dataset_with_fallback
910
import os
1011

12+
TEST_MESHFILES = Path(__file__).resolve().parent.parent / "meshfiles"
13+
1114
def test_open_geoflow_dataset(gridpath, datasetpath):
1215
"""Loads a single dataset with its grid topology file using uxarray's
1316
open_dataset call."""
@@ -163,3 +166,71 @@ def mock_open_dataset(*args, **kwargs):
163166
ds_fallback.close()
164167
if os.path.exists(tmp_path):
165168
os.unlink(tmp_path)
169+
170+
171+
def test_list_grid_names_multigrid(gridpath):
172+
"""List grids from an OASIS-style multi-grid file."""
173+
grid_file = gridpath("scrip", "oasis", "grids.nc")
174+
grid_names = ux.list_grid_names(grid_file)
175+
176+
assert isinstance(grid_names, list)
177+
assert set(grid_names) == {"ocn", "atm"}
178+
179+
180+
def test_list_grid_names_single_scrip():
181+
"""List grids from a standard single-grid SCRIP file."""
182+
grid_path = TEST_MESHFILES / "scrip" / "outCSne8" / "outCSne8.nc"
183+
grid_names = ux.list_grid_names(grid_path)
184+
185+
assert isinstance(grid_names, list)
186+
assert grid_names == ["grid"]
187+
188+
189+
def test_open_multigrid_all_grids(gridpath):
190+
"""Open all grids from a multi-grid file."""
191+
grid_file = gridpath("scrip", "oasis", "grids.nc")
192+
grids = ux.open_multigrid(grid_file)
193+
194+
assert isinstance(grids, dict)
195+
assert set(grids.keys()) == {"ocn", "atm"}
196+
assert grids["ocn"].n_face == 12
197+
assert grids["atm"].n_face == 20
198+
199+
200+
def test_open_multigrid_specific_grids(gridpath):
201+
"""Open a subset of grids from a multi-grid file."""
202+
grid_file = gridpath("scrip", "oasis", "grids.nc")
203+
grids = ux.open_multigrid(grid_file, gridnames=["ocn"])
204+
205+
assert set(grids.keys()) == {"ocn"}
206+
assert grids["ocn"].n_face == 12
207+
208+
209+
def test_open_multigrid_with_masks(gridpath):
210+
"""Open grids with a companion mask file."""
211+
grid_file = gridpath("scrip", "oasis", "grids.nc")
212+
mask_file = gridpath("scrip", "oasis", "masks.nc")
213+
214+
grids = ux.open_multigrid(grid_file, mask_filename=mask_file)
215+
216+
assert grids["ocn"].n_face == 8
217+
assert grids["atm"].n_face == 20
218+
219+
220+
def test_open_multigrid_mask_zero_faces(gridpath):
221+
"""Applying masks that deactivate an entire grid should not fail."""
222+
grid_file = gridpath("scrip", "oasis", "grids.nc")
223+
mask_file = gridpath("scrip", "oasis", "masks_no_atm.nc")
224+
225+
grids = ux.open_multigrid(grid_file, mask_filename=mask_file)
226+
227+
assert grids["ocn"].n_face == 8
228+
assert grids["atm"].n_face == 0
229+
230+
231+
def test_open_multigrid_missing_grid_error(gridpath):
232+
"""Requesting a missing grid should raise."""
233+
grid_file = gridpath("scrip", "oasis", "grids.nc")
234+
235+
with pytest.raises(ValueError, match="Grid 'land' not found"):
236+
ux.open_multigrid(grid_file, gridnames=["land"])

test/grid/grid/test_initialization.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import numpy as np
22
import numpy.testing as nt
33
import pytest
4+
import xarray as xr
45

56
import uxarray as ux
67
from uxarray.constants import INT_FILL_VALUE, ERROR_TOLERANCE
@@ -77,3 +78,23 @@ def test_from_topology():
7778
face_node_connectivity=face_node_connectivity,
7879
fill_value=-1,
7980
)
81+
82+
83+
def test_grid_init_handles_empty_longitude_fields():
84+
"""Ensure grids with empty longitude arrays don't error during initialization."""
85+
empty_lon = np.array([], dtype=np.float64)
86+
ds = xr.Dataset(
87+
{
88+
"node_lon": (("n_node",), empty_lon),
89+
"node_lat": (("n_node",), empty_lon),
90+
"face_node_connectivity": (
91+
("n_face", "n_max_face_nodes"),
92+
np.empty((0, 0), dtype=np.int64),
93+
),
94+
"face_lon": (("n_face",), empty_lon),
95+
}
96+
)
97+
98+
uxgrid = ux.Grid(ds, source_grid_spec="UGRID")
99+
100+
assert uxgrid.n_face == 0

test/io/test_scrip.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
22
import xarray as xr
33
import warnings
4+
import numpy as np
45
import numpy.testing as nt
56
import pytest
67

78
import uxarray as ux
89
from uxarray.constants import INT_DTYPE, INT_FILL_VALUE
10+
from uxarray.io._scrip import _detect_multigrid
911

1012

1113
def test_read_ugrid(gridpath, mesh_constants):
@@ -50,3 +52,69 @@ def test_to_xarray_ugrid(gridpath):
5052
reloaded_grid._ds.close()
5153
del reloaded_grid
5254
os.remove("scrip_ugrid_csne8.nc")
55+
56+
57+
def test_oasis_multigrid_format_detection():
58+
"""Detect OASIS-style multi-grid naming."""
59+
ds = xr.Dataset()
60+
ds["ocn.cla"] = xr.DataArray(np.random.rand(100, 4), dims=["nc_ocn", "nv_ocn"])
61+
ds["ocn.clo"] = xr.DataArray(np.random.rand(100, 4), dims=["nc_ocn", "nv_ocn"])
62+
ds["atm.cla"] = xr.DataArray(np.random.rand(200, 4), dims=["nc_atm", "nv_atm"])
63+
ds["atm.clo"] = xr.DataArray(np.random.rand(200, 4), dims=["nc_atm", "nv_atm"])
64+
65+
format_type, grids = _detect_multigrid(ds)
66+
assert format_type == "multi_scrip"
67+
assert set(grids.keys()) == {"ocn", "atm"}
68+
69+
70+
def test_open_multigrid_with_masks(gridpath):
71+
"""Load OASIS multi-grids with masks applied."""
72+
grid_file = gridpath("scrip", "oasis", "grids.nc")
73+
mask_file = gridpath("scrip", "oasis", "masks.nc")
74+
75+
grids = ux.open_multigrid(grid_file, mask_filename=mask_file)
76+
assert grids["ocn"].n_face == 8
77+
assert grids["atm"].n_face == 20
78+
79+
ocean_only = ux.open_multigrid(
80+
grid_file, gridnames=["ocn"], mask_filename=mask_file
81+
)
82+
assert set(ocean_only.keys()) == {"ocn"}
83+
assert ocean_only["ocn"].n_face == 8
84+
85+
grid_names = ux.list_grid_names(grid_file)
86+
assert set(grid_names) == {"ocn", "atm"}
87+
88+
89+
def test_open_multigrid_mask_active_value_default(gridpath):
90+
"""Default mask semantics keep value==1 active for both grids."""
91+
grid_file = gridpath("scrip", "oasis", "grids.nc")
92+
mask_file = gridpath("scrip", "oasis", "masks_no_atm.nc")
93+
94+
grids = ux.open_multigrid(grid_file, mask_filename=mask_file)
95+
96+
with xr.open_dataset(mask_file) as mask_ds:
97+
expected_ocn = int(mask_ds["ocn.msk"].values.sum())
98+
expected_atm = int(mask_ds["atm.msk"].values.sum())
99+
100+
assert grids["ocn"].n_face == expected_ocn
101+
assert grids["atm"].n_face == expected_atm
102+
103+
104+
def test_open_multigrid_mask_active_value_per_grid_override(gridpath):
105+
"""Per-grid override supports masks with different active values."""
106+
grid_file = gridpath("scrip", "oasis", "grids.nc")
107+
mask_file = gridpath("scrip", "oasis", "masks_no_atm.nc")
108+
109+
grids = ux.open_multigrid(
110+
grid_file,
111+
mask_filename=mask_file,
112+
mask_active_value={"atm": 0, "ocn": 1},
113+
)
114+
115+
with xr.open_dataset(mask_file) as mask_ds:
116+
expected_ocn = int(mask_ds["ocn.msk"].values.sum())
117+
expected_atm = int((mask_ds["atm.msk"].values == 0).sum())
118+
119+
assert grids["ocn"].n_face == expected_ocn
120+
assert grids["atm"].n_face == expected_atm
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# OASIS Multi-Grid SCRIP Test Files
2+
3+
This directory contains small test files for OASIS/YAC multi-grid SCRIP format support in UXarray.
4+
5+
## Files
6+
7+
- `grids.nc`: Multi-grid file containing two grids
8+
- `ocn`: Ocean grid with 12 cells (3x4 regular grid)
9+
- `atm`: Atmosphere grid with 20 cells (4x5 regular grid)
10+
11+
- `masks.nc`: Domain masks for the grids
12+
- `ocn.msk`: Ocean mask (8 ocean cells, 4 land cells)
13+
- `atm.msk`: Atmosphere mask (all 20 cells active)
14+
15+
## OASIS Format
16+
17+
OASIS uses a specific naming convention for multi-grid SCRIP files:
18+
- Grid variables are prefixed with grid name: `<gridname>.<varname>`
19+
- Corner latitudes: `<gridname>.cla`
20+
- Corner longitudes: `<gridname>.clo`
21+
- Dimensions: `nc_<gridname>` (cells), `nv_<gridname>` (corners)
22+
23+
## Usage in Tests
24+
25+
```python
26+
import uxarray as ux
27+
28+
# List available grids
29+
grid_names = ux.list_grid_names("grids.nc")
30+
# ['ocn', 'atm']
31+
32+
# Load all grids
33+
grids = ux.open_multigrid("grids.nc")
34+
35+
# Load with masks
36+
masked_grids = ux.open_multigrid("grids.nc", mask_filename="masks.nc")
37+
# Ocean grid will have 8 cells, atmosphere grid will have 20 cells
38+
```
39+
40+
## File Sizes
41+
42+
These files are intentionally small for fast testing:
43+
- `grids.nc`: ~3 KB
44+
- `masks.nc`: ~1 KB

0 commit comments

Comments
 (0)