Skip to content

Commit c72c3f0

Browse files
authored
Merge pull request #737 from xylar/add-open-dataset
Add `open_dataset` and `open_mfdataset` wrappers to `mpas_tools.io`
2 parents b1b44b5 + 07f776f commit c72c3f0

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

conda_package/docs/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ I/O
151151
:toctree: generated/
152152

153153
write_netcdf
154+
open_dataset
155+
open_mfdataset
154156

155157
Parallelism
156158
-----------

conda_package/docs/io.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,31 @@ Example usage:
2626
# Create a simple dataset
2727
ds = xr.Dataset({'foo': (('x',), [1, 2, 3])})
2828
write_netcdf(ds, 'output.nc')
29+
30+
open_dataset and open_mfdataset
31+
===============================
32+
33+
The :py:func:`mpas_tools.io.open_dataset()` and
34+
:py:func:`mpas_tools.io.open_mfdataset()` functions are thin wrappers around
35+
:py:func:`xarray.open_dataset()` and :py:func:`xarray.open_mfdataset()`. They
36+
select the NetCDF ``engine`` from the module-level
37+
:py:data:`mpas_tools.io.default_engine` variable when an ``engine`` is not
38+
passed explicitly.
39+
40+
This is useful because :py:func:`xarray.open_dataset()` otherwise sniffs the
41+
file for "magic bits" to auto-select a backend, and that probe can crash on
42+
``NETCDF3_64BIT_DATA`` (CDF5) files. xarray provides no global way to set a
43+
default engine, so ``mpas_tools.io.default_engine`` offers a single,
44+
process-wide knob that applies to both reading and writing.
45+
46+
Example usage:
47+
48+
.. code-block:: python
49+
50+
import mpas_tools.io
51+
from mpas_tools.io import open_dataset
52+
53+
# use the netcdf4 engine everywhere to avoid the CDF5 sniffing crash
54+
mpas_tools.io.default_engine = 'netcdf4'
55+
56+
ds = open_dataset('mesh.nc')

conda_package/mpas_tools/io.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import netCDF4
88
import numpy
9+
import xarray
910

1011
from mpas_tools.logging import check_call
1112

@@ -180,6 +181,95 @@ def write_netcdf(
180181
os.remove(out_filename)
181182

182183

184+
def open_dataset(filename, engine=None, logger=None, **kwargs):
185+
"""
186+
Open an ``xarray.Dataset`` from a NetCDF file, accounting for quirks
187+
specific to MPAS components. This is a thin wrapper around
188+
:py:func:`xarray.open_dataset` that selects the NetCDF ``engine`` from
189+
``mpas_tools.io.default_engine`` when ``engine`` is not given.
190+
191+
Specifying an ``engine`` explicitly is important because
192+
:py:func:`xarray.open_dataset` otherwise sniffs the file for "magic bits"
193+
to auto-select a backend, and that probe can crash on
194+
``NETCDF3_64BIT_DATA`` (CDF5) files. Setting
195+
``mpas_tools.io.default_engine`` (e.g. to ``'netcdf4'``) provides a single,
196+
process-wide way to avoid that crash without modifying every call site.
197+
198+
Parameters
199+
----------
200+
filename : str or path-like or file-like
201+
The path to the NetCDF file to open, passed on to
202+
:py:func:`xarray.open_dataset`
203+
204+
engine : {'netcdf4', 'scipy', 'h5netcdf'}, optional
205+
The library to use for reading the NetCDF file. The default is
206+
``mpas_tools.io.default_engine``, which can be modified but which
207+
defaults to ``None`` (xarray auto-selects the backend)
208+
209+
logger : logging.Logger, optional
210+
A logger to write messages to. Reserved for future diagnostics; no
211+
error recovery is performed because the CDF5 backend-sniffing failure
212+
is a hard crash that cannot be caught.
213+
214+
**kwargs
215+
Additional keyword arguments passed on to
216+
:py:func:`xarray.open_dataset` (e.g. ``decode_times``, ``decode_cf``,
217+
``mask_and_scale``)
218+
219+
Returns
220+
-------
221+
ds : xarray.Dataset
222+
The opened dataset
223+
"""
224+
if engine is None:
225+
engine = default_engine
226+
227+
return xarray.open_dataset(filename, engine=engine, **kwargs)
228+
229+
230+
def open_mfdataset(paths, engine=None, logger=None, **kwargs):
231+
"""
232+
Open a multi-file ``xarray.Dataset`` from NetCDF files, accounting for
233+
quirks specific to MPAS components. This is a thin wrapper around
234+
:py:func:`xarray.open_mfdataset` that selects the NetCDF ``engine`` from
235+
``mpas_tools.io.default_engine`` when ``engine`` is not given.
236+
237+
See :py:func:`mpas_tools.io.open_dataset` for why specifying an ``engine``
238+
explicitly (via ``mpas_tools.io.default_engine``) is useful for
239+
``NETCDF3_64BIT_DATA`` (CDF5) files.
240+
241+
Parameters
242+
----------
243+
paths : str or sequence of str or path-like
244+
The paths to the NetCDF files to open, passed on to
245+
:py:func:`xarray.open_mfdataset`
246+
247+
engine : {'netcdf4', 'scipy', 'h5netcdf'}, optional
248+
The library to use for reading the NetCDF files. The default is
249+
``mpas_tools.io.default_engine``, which can be modified but which
250+
defaults to ``None`` (xarray auto-selects the backend)
251+
252+
logger : logging.Logger, optional
253+
A logger to write messages to. Reserved for future diagnostics; no
254+
error recovery is performed because the CDF5 backend-sniffing failure
255+
is a hard crash that cannot be caught.
256+
257+
**kwargs
258+
Additional keyword arguments passed on to
259+
:py:func:`xarray.open_mfdataset` (e.g. ``combine``, ``concat_dim``,
260+
``decode_times``)
261+
262+
Returns
263+
-------
264+
ds : xarray.Dataset
265+
The opened dataset
266+
"""
267+
if engine is None:
268+
engine = default_engine
269+
270+
return xarray.open_mfdataset(paths, engine=engine, **kwargs)
271+
272+
183273
def update_history(ds):
184274
"""Add or append history to attributes of a data set"""
185275

conda_package/tests/test_io.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import pytest
66
import xarray as xr
77

8-
from mpas_tools.io import write_netcdf
8+
import mpas_tools.io
9+
from mpas_tools.io import open_dataset, open_mfdataset, write_netcdf
910
from mpas_tools.logging import LoggingContext
1011

1112
from .util import get_test_data_file
@@ -59,6 +60,64 @@ def test_write_netcdf_cdf5_format(tmp_path):
5960
assert not os.path.exists(tmp_file)
6061

6162

63+
def test_open_dataset_basic(tmp_path):
64+
# Write a file then read it back via the wrapper
65+
arr = np.array([1.0, 2.0, 3.0], dtype=np.float32)
66+
ds = xr.Dataset({'foo': (('x',), arr)})
67+
out_file = tmp_path / 'test_open_basic.nc'
68+
write_netcdf(ds, str(out_file))
69+
ds2 = open_dataset(str(out_file))
70+
assert set(ds.dims) == set(ds2.dims)
71+
assert 'foo' in ds2.data_vars
72+
np.testing.assert_array_equal(ds2['foo'].values, arr)
73+
ds2.close()
74+
75+
76+
def test_open_dataset_cdf5(tmp_path):
77+
# Opening a CDF5 (NETCDF3_64BIT_DATA) file with an explicit engine should
78+
# succeed; this exercises the bug the wrapper works around.
79+
arr = np.array([1.0, 2.0, 3.0], dtype=np.float32)
80+
ds = xr.Dataset({'foo': (('x',), arr)})
81+
out_file = tmp_path / 'test_open_cdf5.nc'
82+
write_netcdf(ds, str(out_file), format='NETCDF3_64BIT_DATA')
83+
ds2 = open_dataset(str(out_file), engine='netcdf4')
84+
np.testing.assert_array_equal(ds2['foo'].values, arr)
85+
ds2.close()
86+
87+
88+
def test_open_dataset_default_engine(tmp_path):
89+
# When engine is None, the wrapper should use mpas_tools.io.default_engine
90+
arr = np.array([1.0, 2.0, 3.0], dtype=np.float32)
91+
ds = xr.Dataset({'foo': (('x',), arr)})
92+
out_file = tmp_path / 'test_open_default_engine.nc'
93+
write_netcdf(ds, str(out_file), format='NETCDF3_64BIT_DATA')
94+
saved_engine = mpas_tools.io.default_engine
95+
try:
96+
mpas_tools.io.default_engine = 'netcdf4'
97+
ds2 = open_dataset(str(out_file))
98+
np.testing.assert_array_equal(ds2['foo'].values, arr)
99+
ds2.close()
100+
finally:
101+
mpas_tools.io.default_engine = saved_engine
102+
103+
104+
def test_open_mfdataset(tmp_path):
105+
# Smoke test: write two files along a dimension and open them combined
106+
out_files = []
107+
for index in range(2):
108+
arr = np.array([index], dtype=np.float32)
109+
ds = xr.Dataset({'foo': (('Time',), arr)})
110+
out_file = tmp_path / f'test_open_mf_{index}.nc'
111+
write_netcdf(ds, str(out_file))
112+
out_files.append(str(out_file))
113+
ds2 = open_mfdataset(
114+
out_files, engine='netcdf4', combine='nested', concat_dim='Time'
115+
)
116+
assert ds2.sizes['Time'] == 2
117+
np.testing.assert_array_equal(ds2['foo'].values, [0.0, 1.0])
118+
ds2.close()
119+
120+
62121
def test_write_netcdf_int64_conversion_and_attr(tmp_path):
63122
# Create a dataset with int64 variable and an attribute
64123
arr = np.array([1, 2, 3], dtype=np.int64)

0 commit comments

Comments
 (0)