From 5605c180f38d7da2626bd05ad2acd74b148b7803 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 30 Mar 2026 17:47:59 -0500 Subject: [PATCH 1/8] Support one-file open_dataset --- test/core/test_api.py | 15 +++++++++++++++ uxarray/core/api.py | 20 +++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index 150c2877c..31d529d54 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -39,6 +39,21 @@ def test_open_dataset(gridpath, datasetpath, mesh_constants): nt.assert_equal(len(uxds_var2_ne30.uxgrid._ds.data_vars), mesh_constants['DATAVARS_outCSne30']) nt.assert_equal(uxds_var2_ne30.source_datasets, str(data_path)) + +def test_open_dataset_single_combined_mpas_file(gridpath): + """Loads a combined MPAS grid-and-data file with a single argument.""" + + file_path = gridpath("mpas", "QU", "mesh.QU.1920km.151026.nc") + + uxds_single = ux.open_dataset(file_path) + uxds_pair = ux.open_dataset(file_path, file_path) + + nt.assert_equal(uxds_single.uxgrid.source_grid_spec, "MPAS") + nt.assert_equal(uxds_single.source_datasets, str(file_path)) + nt.assert_equal(uxds_single.sizes["n_face"], uxds_pair.sizes["n_face"]) + nt.assert_equal(set(uxds_single.data_vars), set(uxds_pair.data_vars)) + + def test_open_mf_dataset(gridpath, datasetpath, mesh_constants): """Loads multiple datasets with their grid topology file using uxarray's open_dataset call.""" diff --git a/uxarray/core/api.py b/uxarray/core/api.py index 5edd612b8..7cf8906f0 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -353,7 +353,7 @@ def list_grid_names( def open_dataset( grid_filename_or_obj: str | os.PathLike[Any] | dict | Dataset, - filename_or_obj: str | os.PathLike[Any], + filename_or_obj: str | os.PathLike[Any] | None = None, chunks=None, chunk_grid: bool = True, use_dual: bool | None = False, @@ -368,10 +368,12 @@ def open_dataset( Strings and Path objects are interpreted as a path to a grid file. Xarray Datasets assume that each member variable is in the UGRID conventions and will be used to create a ``ux.Grid``. Similarly, a dictionary containing UGRID variables can be used to create a ``ux.Grid`` - filename_or_obj : str | os.PathLike[Any] + filename_or_obj : str | os.PathLike[Any], optional String or Path object as a path to a netCDF file or an OpenDAP URL that stores the actual data set. It is the same ``filename_or_obj`` in - ``xarray.open_dataset``. + ``xarray.open_dataset``. If omitted, ``grid_filename_or_obj`` is also + used as the data source, allowing combined grid-and-data files to be + opened with a single argument. chunks : int, dict, 'auto' or None, default: None If provided, used to load the grid into dask arrays. @@ -406,10 +408,22 @@ def open_dataset( >>> import uxarray as ux >>> ux_ds = ux.open_dataset("grid_file.nc", "data_file.nc") + + Open a dataset stored in a single combined grid-and-data file + + >>> ux_ds = ux.open_dataset("combined_file.nc") """ if grid_kwargs is None: grid_kwargs = {} + if filename_or_obj is None: + if isinstance(grid_filename_or_obj, (str, os.PathLike)): + filename_or_obj = grid_filename_or_obj + else: + raise ValueError( + "If filename_or_obj is omitted, grid_filename_or_obj must be a file path." + ) + # Construct a Grid, validate parameters, and correct chunks uxgrid, corrected_chunks = _get_grid( grid_filename_or_obj, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs From bfb6e17d72c209da9dcc63f3f5dbc14a9852047f Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 1 Apr 2026 12:39:57 -0500 Subject: [PATCH 2/8] Update test/core/test_api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/core/test_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index 31d529d54..dba6c96ca 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -43,11 +43,14 @@ def test_open_dataset(gridpath, datasetpath, mesh_constants): def test_open_dataset_single_combined_mpas_file(gridpath): """Loads a combined MPAS grid-and-data file with a single argument.""" - file_path = gridpath("mpas", "QU", "mesh.QU.1920km.151026.nc") + # Use a known combined grid-and-data MPAS file + file_path = gridpath("mpas", "oQU480", "oQU480.231010.nc") uxds_single = ux.open_dataset(file_path) uxds_pair = ux.open_dataset(file_path, file_path) + # Ensure that the single-argument path actually loads data variables + assert len(uxds_single.data_vars) > 0 nt.assert_equal(uxds_single.uxgrid.source_grid_spec, "MPAS") nt.assert_equal(uxds_single.source_datasets, str(file_path)) nt.assert_equal(uxds_single.sizes["n_face"], uxds_pair.sizes["n_face"]) From 7edf0e14cec9fa0343af60fc7d9f88ea0a568ad8 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 1 Apr 2026 13:09:51 -0500 Subject: [PATCH 3/8] Refine one-file dataset loading --- test/core/test_api.py | 12 ++++++++++-- uxarray/core/api.py | 35 ++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index dba6c96ca..cd161369e 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -43,8 +43,8 @@ def test_open_dataset(gridpath, datasetpath, mesh_constants): def test_open_dataset_single_combined_mpas_file(gridpath): """Loads a combined MPAS grid-and-data file with a single argument.""" - # Use a known combined grid-and-data MPAS file - file_path = gridpath("mpas", "oQU480", "oQU480.231010.nc") + # Use a known combined grid-and-data MPAS file. + file_path = gridpath("mpas", "QU", "oQU480.231010.nc") uxds_single = ux.open_dataset(file_path) uxds_pair = ux.open_dataset(file_path, file_path) @@ -55,6 +55,14 @@ def test_open_dataset_single_combined_mpas_file(gridpath): nt.assert_equal(uxds_single.source_datasets, str(file_path)) nt.assert_equal(uxds_single.sizes["n_face"], uxds_pair.sizes["n_face"]) nt.assert_equal(set(uxds_single.data_vars), set(uxds_pair.data_vars)) + assert "ssh" in uxds_single.data_vars + + +def test_open_dataset_single_argument_rejects_directory_grid(tmp_path): + """Requires a separate data file for directory-based grids.""" + + with pytest.raises(ValueError, match="Directory-based grids require a separate data file"): + ux.open_dataset(tmp_path) def test_open_mf_dataset(gridpath, datasetpath, mesh_constants): diff --git a/uxarray/core/api.py b/uxarray/core/api.py index 7cf8906f0..01681e431 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -418,19 +418,40 @@ def open_dataset( if filename_or_obj is None: if isinstance(grid_filename_or_obj, (str, os.PathLike)): + if os.path.isdir(grid_filename_or_obj): + raise ValueError( + "Directory-based grids require a separate data file when calling ux.open_dataset()." + ) + filename_or_obj = grid_filename_or_obj + + if "latlon" in kwargs: + warn( + "'latlon is no longer a supported parameter", + DeprecationWarning, + stacklevel=2, + ) + grid_kwargs["latlon"] = kwargs["latlon"] + + corrected_chunks = match_chunks_to_ugrid(grid_filename_or_obj, chunks) + ds = _open_dataset_with_fallback( + filename_or_obj, chunks=corrected_chunks, **kwargs + ) + uxgrid = open_grid(ds, use_dual=use_dual, **grid_kwargs) else: raise ValueError( "If filename_or_obj is omitted, grid_filename_or_obj must be a file path." ) + else: + # Construct a Grid, validate parameters, and correct chunks + uxgrid, corrected_chunks = _get_grid( + grid_filename_or_obj, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs + ) - # Construct a Grid, validate parameters, and correct chunks - uxgrid, corrected_chunks = _get_grid( - grid_filename_or_obj, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs - ) - - # Load the data as a Xarray Dataset - ds = _open_dataset_with_fallback(filename_or_obj, chunks=corrected_chunks, **kwargs) + # Load the data as a Xarray Dataset + ds = _open_dataset_with_fallback( + filename_or_obj, chunks=corrected_chunks, **kwargs + ) # Map original dimensions to the UGRID conventions ds = _map_dims_to_ugrid(ds, uxgrid._source_dims_dict, uxgrid) From 2dd95de5df5503a7449989f059f2fd34e472b9fc Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 1 Apr 2026 21:23:43 -0500 Subject: [PATCH 4/8] Address review comments --- test/core/test_api.py | 22 ++++++++++++++++++ uxarray/core/api.py | 53 +++++++++++++++++++++++-------------------- uxarray/core/utils.py | 6 ++++- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index cd161369e..090be9ebf 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -58,6 +58,19 @@ def test_open_dataset_single_combined_mpas_file(gridpath): assert "ssh" in uxds_single.data_vars +def test_open_dataset_single_combined_xarray_dataset(gridpath): + """Loads a combined MPAS grid-and-data xarray.Dataset with a single argument.""" + + file_path = gridpath("mpas", "QU", "oQU480.231010.nc") + + with xr.open_dataset(file_path) as ds: + uxds = ux.open_dataset(ds) + + nt.assert_equal(uxds.uxgrid.source_grid_spec, "MPAS") + nt.assert_equal(uxds.source_datasets, None) + assert "ssh" in uxds.data_vars + + def test_open_dataset_single_argument_rejects_directory_grid(tmp_path): """Requires a separate data file for directory-based grids.""" @@ -65,6 +78,15 @@ def test_open_dataset_single_argument_rejects_directory_grid(tmp_path): ux.open_dataset(tmp_path) +def test_open_dataset_single_argument_rejects_invalid_combined_file(datasetpath): + """Rejects one-file inputs that do not contain recognizable grid metadata.""" + + data_path = datasetpath("ugrid", "outCSne30", "outCSne30_var2.nc") + + with pytest.raises(RuntimeError, match="Could not recognize dataset format"): + ux.open_dataset(data_path) + + def test_open_mf_dataset(gridpath, datasetpath, mesh_constants): """Loads multiple datasets with their grid topology file using uxarray's open_dataset call.""" diff --git a/uxarray/core/api.py b/uxarray/core/api.py index 01681e431..affd739c5 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -353,7 +353,7 @@ def list_grid_names( def open_dataset( grid_filename_or_obj: str | os.PathLike[Any] | dict | Dataset, - filename_or_obj: str | os.PathLike[Any] | None = None, + filename_or_obj: str | os.PathLike[Any] | Dataset | None = None, chunks=None, chunk_grid: bool = True, use_dual: bool | None = False, @@ -364,15 +364,16 @@ def open_dataset( Parameters ---------- - grid_filename_or_obj : str | os.PathLike[Any] | dict | xr.dataset + grid_filename_or_obj : str | os.PathLike[Any] | dict | xr.Dataset Strings and Path objects are interpreted as a path to a grid file. Xarray Datasets assume that each member variable is in the UGRID conventions and will be used to create a ``ux.Grid``. Similarly, a dictionary containing UGRID variables can be used to create a ``ux.Grid`` - filename_or_obj : str | os.PathLike[Any], optional + filename_or_obj : str | os.PathLike[Any] | xr.Dataset, optional String or Path object as a path to a netCDF file or an OpenDAP URL that - stores the actual data set. It is the same ``filename_or_obj`` in - ``xarray.open_dataset``. If omitted, ``grid_filename_or_obj`` is also - used as the data source, allowing combined grid-and-data files to be + stores the actual data set, or an already-open ``xarray.Dataset``. It + is the same ``filename_or_obj`` in ``xarray.open_dataset``. If omitted, + ``grid_filename_or_obj`` is also used as the data source, allowing + combined grid-and-data files or ``xarray.Dataset`` objects to be opened with a single argument. chunks : int, dict, 'auto' or None, default: None If provided, used to load the grid into dask arrays. @@ -413,6 +414,8 @@ def open_dataset( >>> ux_ds = ux.open_dataset("combined_file.nc") """ + import xarray as xr + if grid_kwargs is None: grid_kwargs = {} @@ -423,25 +426,20 @@ def open_dataset( "Directory-based grids require a separate data file when calling ux.open_dataset()." ) - filename_or_obj = grid_filename_or_obj - - if "latlon" in kwargs: - warn( - "'latlon is no longer a supported parameter", - DeprecationWarning, - stacklevel=2, - ) - grid_kwargs["latlon"] = kwargs["latlon"] - - corrected_chunks = match_chunks_to_ugrid(grid_filename_or_obj, chunks) ds = _open_dataset_with_fallback( - filename_or_obj, chunks=corrected_chunks, **kwargs + grid_filename_or_obj, + chunks=match_chunks_to_ugrid(grid_filename_or_obj, chunks), + **kwargs, ) - uxgrid = open_grid(ds, use_dual=use_dual, **grid_kwargs) + elif isinstance(grid_filename_or_obj, xr.Dataset): + ds = grid_filename_or_obj else: raise ValueError( - "If filename_or_obj is omitted, grid_filename_or_obj must be a file path." + "If filename_or_obj is omitted, grid_filename_or_obj must be a file path or xarray.Dataset." ) + + uxgrid, _ = _get_grid(ds, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs) + filename_or_obj = grid_filename_or_obj else: # Construct a Grid, validate parameters, and correct chunks uxgrid, corrected_chunks = _get_grid( @@ -449,15 +447,22 @@ def open_dataset( ) # Load the data as a Xarray Dataset - ds = _open_dataset_with_fallback( - filename_or_obj, chunks=corrected_chunks, **kwargs - ) + if isinstance(filename_or_obj, xr.Dataset): + ds = filename_or_obj + else: + ds = _open_dataset_with_fallback( + filename_or_obj, chunks=corrected_chunks, **kwargs + ) # Map original dimensions to the UGRID conventions ds = _map_dims_to_ugrid(ds, uxgrid._source_dims_dict, uxgrid) # Create a UXarray Dataset by linking the Xarray Dataset with a UXarray Grid - return UxDataset(ds, uxgrid=uxgrid, source_datasets=str(filename_or_obj)) + source_datasets = ( + None if isinstance(filename_or_obj, xr.Dataset) else str(filename_or_obj) + ) + + return UxDataset(ds, uxgrid=uxgrid, source_datasets=source_datasets) def open_mfdataset( diff --git a/uxarray/core/utils.py b/uxarray/core/utils.py index 8092022dd..1a05926aa 100644 --- a/uxarray/core/utils.py +++ b/uxarray/core/utils.py @@ -104,7 +104,11 @@ def match_chunks_to_ugrid(grid_filename_or_obj, chunks): # No need to rename return chunks - ds = _open_dataset_with_fallback(grid_filename_or_obj, chunks=chunks) + if isinstance(grid_filename_or_obj, xr.Dataset): + ds = grid_filename_or_obj + else: + ds = _open_dataset_with_fallback(grid_filename_or_obj, chunks=chunks) + grid_spec, _, _ = _parse_grid_type(ds) source_dims_dict = _get_source_dims_dict(ds, grid_spec) From 59f6dd75ba04e802e87e7d903ac78ec958a5d690 Mon Sep 17 00:00:00 2001 From: Sam Evans Date: Tue, 14 Jul 2026 16:20:48 -0400 Subject: [PATCH 5/8] improve error message when can't parse grid being able to ux.open_dataset(single_file) makes it more likely to accidentally call ux.open_dataset(data_file), which crashes here. Old message was "Could not recognize dataset format", which confused me, because the data_file certainly stores a valid dataset... New message clarifies it is an issue with not being able to get a uxgrid from this xarray.Dataset. This should provide a much better hint in the ux.open_dataset(data_file) case that the issue is the file doesn't contain enough info to make a grid. --- test/core/test_api.py | 2 +- test/io/test_utils.py | 2 +- uxarray/io/utils.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index 090be9ebf..6711e51e5 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -83,7 +83,7 @@ def test_open_dataset_single_argument_rejects_invalid_combined_file(datasetpath) data_path = datasetpath("ugrid", "outCSne30", "outCSne30_var2.nc") - with pytest.raises(RuntimeError, match="Could not recognize dataset format"): + with pytest.raises(RuntimeError, match="Failed to parse uxgrid information from xarray.Dataset."): ux.open_dataset(data_path) diff --git a/test/io/test_utils.py b/test/io/test_utils.py index 1e60e88c8..d93222991 100644 --- a/test/io/test_utils.py +++ b/test/io/test_utils.py @@ -61,5 +61,5 @@ def test_parse_grid_type_detects_structured_grid(): ], ) def test_parse_grid_type_rejects_incomplete_format_signals(dataset): - with pytest.raises(RuntimeError, match="Could not recognize dataset format"): + with pytest.raises(RuntimeError, match="Failed to parse uxgrid information from xarray.Dataset."): _parse_grid_type(dataset) diff --git a/uxarray/io/utils.py b/uxarray/io/utils.py index 23b7faeb8..59031e5db 100644 --- a/uxarray/io/utils.py +++ b/uxarray/io/utils.py @@ -120,7 +120,7 @@ def _parse_grid_type(dataset): mesh_type = "Structured" return mesh_type, lon_name, lat_name else: - raise RuntimeError("Could not recognize dataset format.") + raise RuntimeError("Failed to parse uxgrid information from xarray.Dataset.") return mesh_type, None, None From f263fae38e29c3d1dd114c7a92b072fb2560f3fd Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 15 Jul 2026 12:27:48 -0500 Subject: [PATCH 6/8] Clarify one-file open_dataset docstring and directory error message --- test/core/test_api.py | 4 +++- uxarray/core/api.py | 22 +++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index 6711e51e5..0d4de8650 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -74,7 +74,9 @@ def test_open_dataset_single_combined_xarray_dataset(gridpath): def test_open_dataset_single_argument_rejects_directory_grid(tmp_path): """Requires a separate data file for directory-based grids.""" - with pytest.raises(ValueError, match="Directory-based grids require a separate data file"): + with pytest.raises( + ValueError, match="single directory argument is not supported" + ): ux.open_dataset(tmp_path) diff --git a/uxarray/core/api.py b/uxarray/core/api.py index affd739c5..237eb2f8f 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -365,16 +365,21 @@ def open_dataset( Parameters ---------- grid_filename_or_obj : str | os.PathLike[Any] | dict | xr.Dataset - Strings and Path objects are interpreted as a path to a grid file. Xarray Datasets assume that - each member variable is in the UGRID conventions and will be used to create a ``ux.Grid``. Similarly, a dictionary - containing UGRID variables can be used to create a ``ux.Grid`` + Grid information for the ``UxDataset``. Strings and Path objects are interpreted as a path to a grid + file. Xarray Datasets assume that each member variable is in the UGRID conventions and will be used to + create a ``ux.Grid``. Similarly, a dictionary containing UGRID variables can be used to create a + ``ux.Grid``. A path to a directory containing grid files (e.g. a FESOM2 ASCII grid) is also accepted, + but only when ``filename_or_obj`` is provided; directory input is not supported in the single-argument + form below. filename_or_obj : str | os.PathLike[Any] | xr.Dataset, optional String or Path object as a path to a netCDF file or an OpenDAP URL that stores the actual data set, or an already-open ``xarray.Dataset``. It is the same ``filename_or_obj`` in ``xarray.open_dataset``. If omitted, - ``grid_filename_or_obj`` is also used as the data source, allowing - combined grid-and-data files or ``xarray.Dataset`` objects to be - opened with a single argument. + ``grid_filename_or_obj`` is also used as the data source, allowing a + combined grid-and-data file or ``xarray.Dataset`` to be opened with a + single argument. In this single-argument form the file is used both to + build the grid and to load the data; any non-grid variables it contains + are read as data. chunks : int, dict, 'auto' or None, default: None If provided, used to load the grid into dask arrays. @@ -423,7 +428,10 @@ def open_dataset( if isinstance(grid_filename_or_obj, (str, os.PathLike)): if os.path.isdir(grid_filename_or_obj): raise ValueError( - "Directory-based grids require a separate data file when calling ux.open_dataset()." + "ux.open_dataset() with a single directory argument is not supported. " + "Supply a path to a grid file instead. Directory-based grids (e.g. a " + "FESOM2 ASCII grid) are only recognized when a separate data file is " + "also provided, i.e. ux.open_dataset(grid_directory, data_file)." ) ds = _open_dataset_with_fallback( From 01bccc06764577dee72a6c2f18c53e4acd460eca Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 15 Jul 2026 12:33:00 -0500 Subject: [PATCH 7/8] Document that partial or non-Earth grids can be opened --- uxarray/core/api.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/uxarray/core/api.py b/uxarray/core/api.py index 237eb2f8f..aefa30855 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -408,6 +408,13 @@ def open_dataset( uxds : uxarray.UxDataset Dataset with linked `uxgrid` property of type `Grid`. + Notes + ----- + The grid need not be a complete or fully UGRID-compliant mesh, nor represent + a spherical/Earth-science domain. A partial grid can be opened; operations + requiring information absent from the file will raise when used, but + unrelated functionality remains available. + Examples -------- Open a dataset with a grid file and data file From 05fba3fef758817ab50a6b8efa922a9e015e9efd Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Thu, 16 Jul 2026 16:59:19 -0500 Subject: [PATCH 8/8] Reword single-argument open_dataset docstring per review --- uxarray/core/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uxarray/core/api.py b/uxarray/core/api.py index aefa30855..f877c6eb2 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -377,9 +377,9 @@ def open_dataset( is the same ``filename_or_obj`` in ``xarray.open_dataset``. If omitted, ``grid_filename_or_obj`` is also used as the data source, allowing a combined grid-and-data file or ``xarray.Dataset`` to be opened with a - single argument. In this single-argument form the file is used both to - build the grid and to load the data; any non-grid variables it contains - are read as data. + single argument. In this single-argument form the file is used to build + the grid and also treated as data; all variables it contains, including + grid variables, will be included as data. chunks : int, dict, 'auto' or None, default: None If provided, used to load the grid into dask arrays.