From 5d30f96e94de6922a01ad240dec65d2810e19ea0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Dec 2021 00:20:08 -0800 Subject: [PATCH 01/32] [pre-commit.ci] pre-commit autoupdate (#6115) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12e2f97a0cc..1007226d256 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ # https://pre-commit.com/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer From 6b11d6e80a2445f2e1e7c4d5895008f7c845c4d5 Mon Sep 17 00:00:00 2001 From: Michael Delgado Date: Tue, 28 Dec 2021 22:17:33 -0800 Subject: [PATCH 02/32] assert ds errors in test_dataset.py (#6123) a number of assert statements in test_dataset.py::test_clip make assertions which will never fail as long as there is at least one data_variable: ```python assert result.min(...) >= 0.5 ``` this evaluates to datasets with scalar True or False values in each data_variable; however, ds.__bool__ returns true if `len(ds.data_vars) > 0`. related: https://github.com/pydata/xarray/pull/6122 --- xarray/tests/test_dataset.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py index 16148c21b43..c8770601c30 100644 --- a/xarray/tests/test_dataset.py +++ b/xarray/tests/test_dataset.py @@ -6497,14 +6497,14 @@ def test_deepcopy_obj_array(): def test_clip(ds): result = ds.clip(min=0.5) - assert result.min(...) >= 0.5 + assert all((result.min(...) >= 0.5).values()) result = ds.clip(max=0.5) - assert result.max(...) <= 0.5 + assert all((result.max(...) <= 0.5).values()) result = ds.clip(min=0.25, max=0.75) - assert result.min(...) >= 0.25 - assert result.max(...) <= 0.75 + assert all((result.min(...) >= 0.25).values()) + assert all((result.max(...) <= 0.75).values()) result = ds.clip(min=ds.mean("y"), max=ds.mean("y")) assert result.dims == ds.dims From 92ac89f8a19368d1e6d18529997f68661e8e7784 Mon Sep 17 00:00:00 2001 From: Michael Delgado Date: Tue, 28 Dec 2021 22:24:50 -0800 Subject: [PATCH 03/32] assert ds errors in test_backends (#6122) `assert ds0 == ds2` will always evaluate to True if the datasets have at least one data variable. Instead, xr.testing.assert_equal should be used to test data variable equality. --- xarray/tests/test_backends.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py index af4fb77a7fb..c4183f2cdc9 100644 --- a/xarray/tests/test_backends.py +++ b/xarray/tests/test_backends.py @@ -5209,22 +5209,22 @@ def test_open_fsspec(): # single dataset url = "memory://out2.zarr" ds2 = open_dataset(url, engine="zarr") - assert ds0 == ds2 + xr.testing.assert_equal(ds0, ds2) # single dataset with caching url = "simplecache::memory://out2.zarr" ds2 = open_dataset(url, engine="zarr") - assert ds0 == ds2 + xr.testing.assert_equal(ds0, ds2) # multi dataset url = "memory://out*.zarr" ds2 = open_mfdataset(url, engine="zarr") - assert xr.concat([ds, ds0], dim="time") == ds2 + xr.testing.assert_equal(xr.concat([ds, ds0], dim="time"), ds2) # multi dataset with caching url = "simplecache::memory://out*.zarr" ds2 = open_mfdataset(url, engine="zarr") - assert xr.concat([ds, ds0], dim="time") == ds2 + xr.testing.assert_equal(xr.concat([ds, ds0], dim="time"), ds2) @requires_h5netcdf From 379b5b757e5e84628629091296b099b856da1682 Mon Sep 17 00:00:00 2001 From: Illviljan <14371165+Illviljan@users.noreply.github.com> Date: Wed, 29 Dec 2021 08:54:37 +0100 Subject: [PATCH 04/32] Add support for cross product (#5365) * Add support for cross * Update test_computation.py * Update computation.py * Update computation.py * Update test_computation.py * Update test_computation.py * Update test_computation.py * add more tests * Update xarray/core/computation.py Co-authored-by: keewis * spatial_dim to dim * Update computation.py * use pad instead of concat * copy paste np.cross intro * Get last dim for each array, which is more inline with np.cross * examples in docs * Update computation.py * more doc examples * single dim required, tranpose after apply_ufunc * add dims to tests * Update computation.py * reduce code * support xr.Variable * Update computation.py * Update computation.py * reduce code * docstring explanations * Use same terms * docstring formatting * reduce code * add tests for dask * simplify check, align used variables * trim down tests * Update computation.py * simplify code * Add type hints * less type hints * Update computation.py * undo type hints * Update computation.py * Add support for datasets * determine dtype with np.result_type * test datasets, daskify the inputs not the results * rechunk padded values, handle 1 sized datasets * expand only unique dims, squeeze out dims in tests * rechunk along the dim * Attempt typing again * Update __init__.py * Update computation.py * Update computation.py * test fixing type in to_stacked_array * test fixing to_stacked_array * small is large * Update computation.py * Update xarray/core/computation.py Co-authored-by: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> * obfuscate variable_dim some * Update computation.py * undo to_stacked_array changes * test sample_dims typing * to_stacked_array fixes * add reindex_like check * Update computation.py * Update computation.py * Update computation.py * test forcing int type in chunk() * Update computation.py * test collection in to_stacked_array * Update computation.py * Update computation.py * Update computation.py * Update computation.py * Update computation.py * whats new and api.rst * Update whats-new.rst * Output as dataset if any input is a dataset * Simplify the if terms instead of using pass. * Update computation.py * Remove support for datasets * Update computation.py * Add some typing to test. * doctest fix * lint * Update xarray/core/computation.py Co-authored-by: keewis * Update xarray/core/computation.py Co-authored-by: keewis * Update xarray/core/computation.py Co-authored-by: keewis * Update computation.py * Update computation.py * Update computation.py * Update computation.py * Update computation.py * Can't narrow types with old type Seems using bounds in typevar makes it impossible to narrow the type using isinstance checks. * dim now keyword only * use all_dims in transpose * if in transpose indeed needed if a and b has size 2 it's needed. * Update xarray/core/computation.py Co-authored-by: keewis * Update xarray/core/computation.py Co-authored-by: keewis * Update xarray/core/computation.py Co-authored-by: keewis * Update computation.py * Update computation.py * add todo comments * Update whats-new.rst Co-authored-by: keewis Co-authored-by: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> --- doc/api.rst | 1 + doc/whats-new.rst | 2 + xarray/__init__.py | 12 +- xarray/core/computation.py | 209 +++++++++++++++++++++++++++++++ xarray/tests/test_computation.py | 107 ++++++++++++++++ 5 files changed, 330 insertions(+), 1 deletion(-) diff --git a/doc/api.rst b/doc/api.rst index 9433ecfa56d..ef2694ea661 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -32,6 +32,7 @@ Top-level functions ones_like cov corr + cross dot polyval map_blocks diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 1c4b49097a3..bd6097d61fe 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -21,6 +21,8 @@ v0.21.0 (unreleased) New Features ~~~~~~~~~~~~ +- New top-level function :py:func:`cross`. (:issue:`3279`, :pull:`5365`). + By `Jimmy Westling `_. Breaking changes diff --git a/xarray/__init__.py b/xarray/__init__.py index 10f16e58081..81ab9f388a8 100644 --- a/xarray/__init__.py +++ b/xarray/__init__.py @@ -16,7 +16,16 @@ from .core.alignment import align, broadcast from .core.combine import combine_by_coords, combine_nested from .core.common import ALL_DIMS, full_like, ones_like, zeros_like -from .core.computation import apply_ufunc, corr, cov, dot, polyval, unify_chunks, where +from .core.computation import ( + apply_ufunc, + corr, + cov, + cross, + dot, + polyval, + unify_chunks, + where, +) from .core.concat import concat from .core.dataarray import DataArray from .core.dataset import Dataset @@ -60,6 +69,7 @@ "dot", "cov", "corr", + "cross", "full_like", "get_options", "infer_freq", diff --git a/xarray/core/computation.py b/xarray/core/computation.py index 191b777107a..9fe93c88734 100644 --- a/xarray/core/computation.py +++ b/xarray/core/computation.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from .coordinates import Coordinates + from .dataarray import DataArray from .dataset import Dataset from .types import T_Xarray @@ -1373,6 +1374,214 @@ def _cov_corr(da_a, da_b, dim=None, ddof=0, method=None): return corr +def cross( + a: Union[DataArray, Variable], b: Union[DataArray, Variable], *, dim: Hashable +) -> Union[DataArray, Variable]: + """ + Compute the cross product of two (arrays of) vectors. + + The cross product of `a` and `b` in :math:`R^3` is a vector + perpendicular to both `a` and `b`. The vectors in `a` and `b` are + defined by the values along the dimension `dim` and can have sizes + 1, 2 or 3. Where the size of either `a` or `b` is + 1 or 2, the remaining components of the input vector is assumed to + be zero and the cross product calculated accordingly. In cases where + both input vectors have dimension 2, the z-component of the cross + product is returned. + + Parameters + ---------- + a, b : DataArray or Variable + Components of the first and second vector(s). + dim : hashable + The dimension along which the cross product will be computed. + Must be available in both vectors. + + Examples + -------- + Vector cross-product with 3 dimensions: + + >>> a = xr.DataArray([1, 2, 3]) + >>> b = xr.DataArray([4, 5, 6]) + >>> xr.cross(a, b, dim="dim_0") + + array([-3, 6, -3]) + Dimensions without coordinates: dim_0 + + Vector cross-product with 2 dimensions, returns in the perpendicular + direction: + + >>> a = xr.DataArray([1, 2]) + >>> b = xr.DataArray([4, 5]) + >>> xr.cross(a, b, dim="dim_0") + + array(-3) + + Vector cross-product with 3 dimensions but zeros at the last axis + yields the same results as with 2 dimensions: + + >>> a = xr.DataArray([1, 2, 0]) + >>> b = xr.DataArray([4, 5, 0]) + >>> xr.cross(a, b, dim="dim_0") + + array([ 0, 0, -3]) + Dimensions without coordinates: dim_0 + + One vector with dimension 2: + + >>> a = xr.DataArray( + ... [1, 2], + ... dims=["cartesian"], + ... coords=dict(cartesian=(["cartesian"], ["x", "y"])), + ... ) + >>> b = xr.DataArray( + ... [4, 5, 6], + ... dims=["cartesian"], + ... coords=dict(cartesian=(["cartesian"], ["x", "y", "z"])), + ... ) + >>> xr.cross(a, b, dim="cartesian") + + array([12, -6, -3]) + Coordinates: + * cartesian (cartesian) >> a = xr.DataArray( + ... [1, 2], + ... dims=["cartesian"], + ... coords=dict(cartesian=(["cartesian"], ["x", "z"])), + ... ) + >>> b = xr.DataArray( + ... [4, 5, 6], + ... dims=["cartesian"], + ... coords=dict(cartesian=(["cartesian"], ["x", "y", "z"])), + ... ) + >>> xr.cross(a, b, dim="cartesian") + + array([-10, 2, 5]) + Coordinates: + * cartesian (cartesian) >> a = xr.DataArray( + ... [[1, 2, 3], [4, 5, 6]], + ... dims=("time", "cartesian"), + ... coords=dict( + ... time=(["time"], [0, 1]), + ... cartesian=(["cartesian"], ["x", "y", "z"]), + ... ), + ... ) + >>> b = xr.DataArray( + ... [[4, 5, 6], [1, 2, 3]], + ... dims=("time", "cartesian"), + ... coords=dict( + ... time=(["time"], [0, 1]), + ... cartesian=(["cartesian"], ["x", "y", "z"]), + ... ), + ... ) + >>> xr.cross(a, b, dim="cartesian") + + array([[-3, 6, -3], + [ 3, -6, 3]]) + Coordinates: + * time (time) int64 0 1 + * cartesian (cartesian) >> ds_a = xr.Dataset(dict(x=("dim_0", [1]), y=("dim_0", [2]), z=("dim_0", [3]))) + >>> ds_b = xr.Dataset(dict(x=("dim_0", [4]), y=("dim_0", [5]), z=("dim_0", [6]))) + >>> c = xr.cross( + ... ds_a.to_array("cartesian"), ds_b.to_array("cartesian"), dim="cartesian" + ... ) + >>> c.to_dataset(dim="cartesian") + + Dimensions: (dim_0: 1) + Dimensions without coordinates: dim_0 + Data variables: + x (dim_0) int64 -3 + y (dim_0) int64 6 + z (dim_0) int64 -3 + + See Also + -------- + numpy.cross : Corresponding numpy function + """ + + if dim not in a.dims: + raise ValueError(f"Dimension {dim!r} not on a") + elif dim not in b.dims: + raise ValueError(f"Dimension {dim!r} not on b") + + if not 1 <= a.sizes[dim] <= 3: + raise ValueError( + f"The size of {dim!r} on a must be 1, 2, or 3 to be " + f"compatible with a cross product but is {a.sizes[dim]}" + ) + elif not 1 <= b.sizes[dim] <= 3: + raise ValueError( + f"The size of {dim!r} on b must be 1, 2, or 3 to be " + f"compatible with a cross product but is {b.sizes[dim]}" + ) + + all_dims = list(dict.fromkeys(a.dims + b.dims)) + + if a.sizes[dim] != b.sizes[dim]: + # Arrays have different sizes. Append zeros where the smaller + # array is missing a value, zeros will not affect np.cross: + + if ( + not isinstance(a, Variable) # Only used to make mypy happy. + and dim in getattr(a, "coords", {}) + and not isinstance(b, Variable) # Only used to make mypy happy. + and dim in getattr(b, "coords", {}) + ): + # If the arrays have coords we know which indexes to fill + # with zeros: + a, b = align( + a, + b, + fill_value=0, + join="outer", + exclude=set(all_dims) - {dim}, + ) + elif min(a.sizes[dim], b.sizes[dim]) == 2: + # If the array doesn't have coords we can only infer + # that it has composite values if the size is at least 2. + # Once padded, rechunk the padded array because apply_ufunc + # requires core dimensions not to be chunked: + if a.sizes[dim] < b.sizes[dim]: + a = a.pad({dim: (0, 1)}, constant_values=0) + # TODO: Should pad or apply_ufunc handle correct chunking? + a = a.chunk({dim: -1}) if is_duck_dask_array(a.data) else a + else: + b = b.pad({dim: (0, 1)}, constant_values=0) + # TODO: Should pad or apply_ufunc handle correct chunking? + b = b.chunk({dim: -1}) if is_duck_dask_array(b.data) else b + else: + raise ValueError( + f"{dim!r} on {'a' if a.sizes[dim] == 1 else 'b'} is incompatible:" + " dimensions without coordinates must have have a length of 2 or 3" + ) + + c = apply_ufunc( + np.cross, + a, + b, + input_core_dims=[[dim], [dim]], + output_core_dims=[[dim] if a.sizes[dim] == 3 else []], + dask="parallelized", + output_dtypes=[np.result_type(a, b)], + ) + c = c.transpose(*all_dims, missing_dims="ignore") + + return c + + def dot(*arrays, dims=None, **kwargs): """Generalized dot product for xarray objects. Like np.einsum, but provides a simpler interface based on array dimensions. diff --git a/xarray/tests/test_computation.py b/xarray/tests/test_computation.py index 77d3110104f..6af93607e6b 100644 --- a/xarray/tests/test_computation.py +++ b/xarray/tests/test_computation.py @@ -1952,3 +1952,110 @@ def test_polyval(use_dask, use_datetime) -> None: da_pv = xr.polyval(da.x, coeffs) xr.testing.assert_allclose(da, da_pv.T) + + +@pytest.mark.parametrize("use_dask", [False, True]) +@pytest.mark.parametrize( + "a, b, ae, be, dim, axis", + [ + [ + xr.DataArray([1, 2, 3]), + xr.DataArray([4, 5, 6]), + [1, 2, 3], + [4, 5, 6], + "dim_0", + -1, + ], + [ + xr.DataArray([1, 2]), + xr.DataArray([4, 5, 6]), + [1, 2], + [4, 5, 6], + "dim_0", + -1, + ], + [ + xr.Variable(dims=["dim_0"], data=[1, 2, 3]), + xr.Variable(dims=["dim_0"], data=[4, 5, 6]), + [1, 2, 3], + [4, 5, 6], + "dim_0", + -1, + ], + [ + xr.Variable(dims=["dim_0"], data=[1, 2]), + xr.Variable(dims=["dim_0"], data=[4, 5, 6]), + [1, 2], + [4, 5, 6], + "dim_0", + -1, + ], + [ # Test dim in the middle: + xr.DataArray( + np.arange(0, 5 * 3 * 4).reshape((5, 3, 4)), + dims=["time", "cartesian", "var"], + coords=dict( + time=(["time"], np.arange(0, 5)), + cartesian=(["cartesian"], ["x", "y", "z"]), + var=(["var"], [1, 1.5, 2, 2.5]), + ), + ), + xr.DataArray( + np.arange(0, 5 * 3 * 4).reshape((5, 3, 4)) + 1, + dims=["time", "cartesian", "var"], + coords=dict( + time=(["time"], np.arange(0, 5)), + cartesian=(["cartesian"], ["x", "y", "z"]), + var=(["var"], [1, 1.5, 2, 2.5]), + ), + ), + np.arange(0, 5 * 3 * 4).reshape((5, 3, 4)), + np.arange(0, 5 * 3 * 4).reshape((5, 3, 4)) + 1, + "cartesian", + 1, + ], + [ # Test 1 sized arrays with coords: + xr.DataArray( + np.array([1]), + dims=["cartesian"], + coords=dict(cartesian=(["cartesian"], ["z"])), + ), + xr.DataArray( + np.array([4, 5, 6]), + dims=["cartesian"], + coords=dict(cartesian=(["cartesian"], ["x", "y", "z"])), + ), + [0, 0, 1], + [4, 5, 6], + "cartesian", + -1, + ], + [ # Test filling inbetween with coords: + xr.DataArray( + [1, 2], + dims=["cartesian"], + coords=dict(cartesian=(["cartesian"], ["x", "z"])), + ), + xr.DataArray( + [4, 5, 6], + dims=["cartesian"], + coords=dict(cartesian=(["cartesian"], ["x", "y", "z"])), + ), + [1, 0, 2], + [4, 5, 6], + "cartesian", + -1, + ], + ], +) +def test_cross(a, b, ae, be, dim: str, axis: int, use_dask: bool) -> None: + expected = np.cross(ae, be, axis=axis) + + if use_dask: + if not has_dask: + pytest.skip("test for dask.") + a = a.chunk() + b = b.chunk() + + actual = xr.cross(a, b, dim=dim) + xr.testing.assert_duckarray_allclose(expected, actual) From 2694046c748a51125de6d460073635f1d789958e Mon Sep 17 00:00:00 2001 From: Joseph K Aicher <4666753+jaicher@users.noreply.github.com> Date: Wed, 29 Dec 2021 02:56:58 -0500 Subject: [PATCH 05/32] Revert "Single matplotlib import (#5794)" (#6064) This reverts commit ea2886136dec7047186d5a73380d50130a7b5241. Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- asv_bench/benchmarks/import_xarray.py | 9 ------- xarray/plot/dataset_plot.py | 12 ++++++---- xarray/plot/facetgrid.py | 10 ++++++-- xarray/plot/plot.py | 8 ++++++- xarray/plot/utils.py | 34 +++++++++++++++------------ 5 files changed, 42 insertions(+), 31 deletions(-) delete mode 100644 asv_bench/benchmarks/import_xarray.py diff --git a/asv_bench/benchmarks/import_xarray.py b/asv_bench/benchmarks/import_xarray.py deleted file mode 100644 index 94652e3b82a..00000000000 --- a/asv_bench/benchmarks/import_xarray.py +++ /dev/null @@ -1,9 +0,0 @@ -class ImportXarray: - def setup(self, *args, **kwargs): - def import_xr(): - import xarray # noqa: F401 - - self._import_xr = import_xr - - def time_import_xarray(self): - self._import_xr() diff --git a/xarray/plot/dataset_plot.py b/xarray/plot/dataset_plot.py index 7288a368e47..c1aedd570bc 100644 --- a/xarray/plot/dataset_plot.py +++ b/xarray/plot/dataset_plot.py @@ -12,7 +12,6 @@ _process_cmap_cbar_kwargs, get_axis, label_from_attrs, - plt, ) # copied from seaborn @@ -135,7 +134,8 @@ def _infer_scatter_data(ds, x, y, hue, markersize, size_norm, size_mapping=None) # copied from seaborn def _parse_size(data, norm): - mpl = plt.matplotlib + + import matplotlib as mpl if data is None: return None @@ -544,6 +544,8 @@ def quiver(ds, x, y, ax, u, v, **kwargs): Wraps :py:func:`matplotlib:matplotlib.pyplot.quiver`. """ + import matplotlib as mpl + if x is None or y is None or u is None or v is None: raise ValueError("Must specify x, y, u, v for quiver plots.") @@ -558,7 +560,7 @@ def quiver(ds, x, y, ax, u, v, **kwargs): # TODO: Fix this by always returning a norm with vmin, vmax in cmap_params if not cmap_params["norm"]: - cmap_params["norm"] = plt.Normalize( + cmap_params["norm"] = mpl.colors.Normalize( cmap_params.pop("vmin"), cmap_params.pop("vmax") ) @@ -574,6 +576,8 @@ def streamplot(ds, x, y, ax, u, v, **kwargs): Wraps :py:func:`matplotlib:matplotlib.pyplot.streamplot`. """ + import matplotlib as mpl + if x is None or y is None or u is None or v is None: raise ValueError("Must specify x, y, u, v for streamplot plots.") @@ -609,7 +613,7 @@ def streamplot(ds, x, y, ax, u, v, **kwargs): # TODO: Fix this by always returning a norm with vmin, vmax in cmap_params if not cmap_params["norm"]: - cmap_params["norm"] = plt.Normalize( + cmap_params["norm"] = mpl.colors.Normalize( cmap_params.pop("vmin"), cmap_params.pop("vmax") ) diff --git a/xarray/plot/facetgrid.py b/xarray/plot/facetgrid.py index cc6b1ffe777..f3daeeb7f3f 100644 --- a/xarray/plot/facetgrid.py +++ b/xarray/plot/facetgrid.py @@ -9,8 +9,8 @@ _get_nice_quiver_magnitude, _infer_xy_labels, _process_cmap_cbar_kwargs, + import_matplotlib_pyplot, label_from_attrs, - plt, ) # Overrides axes.labelsize, xtick.major.size, ytick.major.size @@ -116,6 +116,8 @@ def __init__( """ + plt = import_matplotlib_pyplot() + # Handle corner case of nonunique coordinates rep_col = col is not None and not data[col].to_index().is_unique rep_row = row is not None and not data[row].to_index().is_unique @@ -517,8 +519,10 @@ def set_titles(self, template="{coord} = {value}", maxchar=30, size=None, **kwar self: FacetGrid object """ + import matplotlib as mpl + if size is None: - size = plt.rcParams["axes.labelsize"] + size = mpl.rcParams["axes.labelsize"] nicetitle = functools.partial(_nicetitle, maxchar=maxchar, template=template) @@ -615,6 +619,8 @@ def map(self, func, *args, **kwargs): self : FacetGrid object """ + plt = import_matplotlib_pyplot() + for ax, namedict in zip(self.axes.flat, self.name_dicts.flat): if namedict is not None: data = self.data.loc[namedict] diff --git a/xarray/plot/plot.py b/xarray/plot/plot.py index 0d6bae29ee2..af860b22635 100644 --- a/xarray/plot/plot.py +++ b/xarray/plot/plot.py @@ -29,9 +29,9 @@ _resolve_intervals_2dplot, _update_axes, get_axis, + import_matplotlib_pyplot, label_from_attrs, legend_elements, - plt, ) # copied from seaborn @@ -83,6 +83,8 @@ def _parse_size(data, norm, width): If the data is categorical, normalize it to numbers. """ + plt = import_matplotlib_pyplot() + if data is None: return None @@ -680,6 +682,8 @@ def scatter( **kwargs : optional Additional keyword arguments to matplotlib """ + plt = import_matplotlib_pyplot() + # Handle facetgrids first if row or col: allargs = locals().copy() @@ -1107,6 +1111,8 @@ def newplotfunc( allargs["plotfunc"] = globals()[plotfunc.__name__] return _easy_facetgrid(darray, kind="dataarray", **allargs) + plt = import_matplotlib_pyplot() + if ( plotfunc.__name__ == "surface" and not kwargs.get("_is_facetgrid", False) diff --git a/xarray/plot/utils.py b/xarray/plot/utils.py index a49302f7f87..9e7e78f4c44 100644 --- a/xarray/plot/utils.py +++ b/xarray/plot/utils.py @@ -47,12 +47,6 @@ def import_matplotlib_pyplot(): return plt -try: - plt = import_matplotlib_pyplot() -except ImportError: - plt = None - - def _determine_extend(calc_data, vmin, vmax): extend_min = calc_data.min() < vmin extend_max = calc_data.max() > vmax @@ -70,7 +64,7 @@ def _build_discrete_cmap(cmap, levels, extend, filled): """ Build a discrete colormap and normalization of the data. """ - mpl = plt.matplotlib + import matplotlib as mpl if len(levels) == 1: levels = [levels[0], levels[0]] @@ -121,7 +115,8 @@ def _build_discrete_cmap(cmap, levels, extend, filled): def _color_palette(cmap, n_colors): - ListedColormap = plt.matplotlib.colors.ListedColormap + import matplotlib.pyplot as plt + from matplotlib.colors import ListedColormap colors_i = np.linspace(0, 1.0, n_colors) if isinstance(cmap, (list, tuple)): @@ -182,7 +177,7 @@ def _determine_cmap_params( cmap_params : dict Use depends on the type of the plotting function """ - mpl = plt.matplotlib + import matplotlib as mpl if isinstance(levels, Iterable): levels = sorted(levels) @@ -290,13 +285,13 @@ def _determine_cmap_params( levels = np.asarray([(vmin + vmax) / 2]) else: # N in MaxNLocator refers to bins, not ticks - ticker = plt.MaxNLocator(levels - 1) + ticker = mpl.ticker.MaxNLocator(levels - 1) levels = ticker.tick_values(vmin, vmax) vmin, vmax = levels[0], levels[-1] # GH3734 if vmin == vmax: - vmin, vmax = plt.LinearLocator(2).tick_values(vmin, vmax) + vmin, vmax = mpl.ticker.LinearLocator(2).tick_values(vmin, vmax) if extend is None: extend = _determine_extend(calc_data, vmin, vmax) @@ -426,7 +421,10 @@ def _assert_valid_xy(darray, xy, name): def get_axis(figsize=None, size=None, aspect=None, ax=None, **kwargs): - if plt is None: + try: + import matplotlib as mpl + import matplotlib.pyplot as plt + except ImportError: raise ImportError("matplotlib is required for plot.utils.get_axis") if figsize is not None: @@ -439,7 +437,7 @@ def get_axis(figsize=None, size=None, aspect=None, ax=None, **kwargs): if ax is not None: raise ValueError("cannot provide both `size` and `ax` arguments") if aspect is None: - width, height = plt.rcParams["figure.figsize"] + width, height = mpl.rcParams["figure.figsize"] aspect = width / height figsize = (size * aspect, size) _, ax = plt.subplots(figsize=figsize) @@ -456,6 +454,9 @@ def get_axis(figsize=None, size=None, aspect=None, ax=None, **kwargs): def _maybe_gca(**kwargs): + + import matplotlib.pyplot as plt + # can call gcf unconditionally: either it exists or would be created by plt.axes f = plt.gcf() @@ -913,7 +914,9 @@ def _process_cmap_cbar_kwargs( def _get_nice_quiver_magnitude(u, v): - ticker = plt.MaxNLocator(3) + import matplotlib as mpl + + ticker = mpl.ticker.MaxNLocator(3) mean = np.mean(np.hypot(u.to_numpy(), v.to_numpy())) magnitude = ticker.tick_values(0, mean)[-2] return magnitude @@ -988,7 +991,7 @@ def legend_elements( """ import warnings - mpl = plt.matplotlib + import matplotlib as mpl mlines = mpl.lines @@ -1125,6 +1128,7 @@ def _legend_add_subtitle(handles, labels, text, func): def _adjust_legend_subtitles(legend): """Make invisible-handle "subtitles" entries look more like titles.""" + plt = import_matplotlib_pyplot() # Legend title not in rcParams until 3.0 font_size = plt.rcParams.get("legend.title_fontsize", None) From e391f131451ca8962106f2a146abd024b91e21e2 Mon Sep 17 00:00:00 2001 From: Mathias Hauser Date: Wed, 29 Dec 2021 17:27:55 +0100 Subject: [PATCH 06/32] is_dask_collection: micro optimization (#6107) --- xarray/core/pycompat.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/xarray/core/pycompat.py b/xarray/core/pycompat.py index 7a40d6e64f8..972d9500777 100644 --- a/xarray/core/pycompat.py +++ b/xarray/core/pycompat.py @@ -43,8 +43,19 @@ def __init__(self, mod): self.available = duck_array_module is not None +dsk = DuckArrayModule("dask") +dask_version = dsk.version +dask_array_type = dsk.type + +sp = DuckArrayModule("sparse") +sparse_array_type = sp.type +sparse_version = sp.version + +cupy_array_type = DuckArrayModule("cupy").type + + def is_dask_collection(x): - if DuckArrayModule("dask").available: + if dsk.available: from dask.base import is_dask_collection return is_dask_collection(x) @@ -54,14 +65,3 @@ def is_dask_collection(x): def is_duck_dask_array(x): return is_duck_array(x) and is_dask_collection(x) - - -dsk = DuckArrayModule("dask") -dask_version = dsk.version -dask_array_type = dsk.type - -sp = DuckArrayModule("sparse") -sparse_array_type = sp.type -sparse_version = sp.version - -cupy_array_type = DuckArrayModule("cupy").type From 89e13b0db9e8efeb1832f4c4f87fe3a43f7b54be Mon Sep 17 00:00:00 2001 From: Anderson Banihirwe Date: Wed, 29 Dec 2021 09:28:45 -0700 Subject: [PATCH 07/32] Remove pre-commit GHA workflow (#6120) --- .github/PULL_REQUEST_TEMPLATE.md | 1 - .github/workflows/ci-pre-commit.yml | 17 ----------------- 2 files changed, 18 deletions(-) delete mode 100644 .github/workflows/ci-pre-commit.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c7ea19a53cb..37b8d357c87 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,5 @@ - [ ] Closes #xxxx - [ ] Tests added -- [ ] Passes `pre-commit run --all-files` - [ ] User visible changes (including notable bug fixes) are documented in `whats-new.rst` - [ ] New functions/methods are listed in `api.rst` diff --git a/.github/workflows/ci-pre-commit.yml b/.github/workflows/ci-pre-commit.yml deleted file mode 100644 index 4bc5bddfdbc..00000000000 --- a/.github/workflows/ci-pre-commit.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: linting - -on: - push: - branches: "*" - pull_request: - branches: "*" - -jobs: - linting: - name: "pre-commit hooks" - runs-on: ubuntu-latest - if: github.repository == 'pydata/xarray' - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.3 From 2957fdf0785af0a1bbb1073049e44cfd4eef933d Mon Sep 17 00:00:00 2001 From: Tom Nicholas <35968931+TomNicholas@users.noreply.github.com> Date: Wed, 29 Dec 2021 11:34:45 -0500 Subject: [PATCH 08/32] Remove lock kwarg (#5912) Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- doc/whats-new.rst | 2 ++ xarray/backends/pydap_.py | 10 ---------- xarray/backends/zarr.py | 8 -------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index bd6097d61fe..f991a4e2a89 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -31,6 +31,8 @@ Breaking changes Deprecations ~~~~~~~~~~~~ +- Removed the lock kwarg from the zarr and pydap backends, completing the deprecation cycle started in :issue:`5256`. + By `Tom Nicholas `_. Bug fixes diff --git a/xarray/backends/pydap_.py b/xarray/backends/pydap_.py index bc479f9a71d..ffaf3793928 100644 --- a/xarray/backends/pydap_.py +++ b/xarray/backends/pydap_.py @@ -1,5 +1,3 @@ -import warnings - import numpy as np from ..core import indexing @@ -126,15 +124,7 @@ def open_dataset( use_cftime=None, decode_timedelta=None, session=None, - lock=None, ): - # TODO remove after v0.19 - if lock is not None: - warnings.warn( - "The kwarg 'lock' has been deprecated for this backend, and is now " - "ignored. In the future passing lock will raise an error.", - DeprecationWarning, - ) store = PydapDataStore.open( filename_or_obj, diff --git a/xarray/backends/zarr.py b/xarray/backends/zarr.py index 3eb6a3caf72..8bd343869ff 100644 --- a/xarray/backends/zarr.py +++ b/xarray/backends/zarr.py @@ -810,15 +810,7 @@ def open_dataset( chunk_store=None, storage_options=None, stacklevel=3, - lock=None, ): - # TODO remove after v0.19 - if lock is not None: - warnings.warn( - "The kwarg 'lock' has been deprecated for this backend, and is now " - "ignored. In the future passing lock will raise an error.", - DeprecationWarning, - ) filename_or_obj = _normalize_path(filename_or_obj) store = ZarrStore.open_group( From 2cb95a82ba9dc421062e8f0e678ce98198977835 Mon Sep 17 00:00:00 2001 From: Anderson Banihirwe Date: Wed, 29 Dec 2021 09:47:47 -0700 Subject: [PATCH 09/32] Replace markdown issue templates with issue forms (#6119) --- .github/ISSUE_TEMPLATE/bug-report.md | 39 --------------- .github/ISSUE_TEMPLATE/bugreport.yml | 61 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature-request.md | 22 -------- .github/ISSUE_TEMPLATE/newfeature.yml | 37 ++++++++++++++ 4 files changed, 98 insertions(+), 61 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/bugreport.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/ISSUE_TEMPLATE/newfeature.yml diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index 02bc5d0f7b0..00000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - - - -**What happened**: - -**What you expected to happen**: - -**Minimal Complete Verifiable Example**: - -```python -# Put your MCVE code here -``` - -**Anything else we need to know?**: - -**Environment**: - -
Output of xr.show_versions() - - - - -
diff --git a/.github/ISSUE_TEMPLATE/bugreport.yml b/.github/ISSUE_TEMPLATE/bugreport.yml new file mode 100644 index 00000000000..255c7de07d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bugreport.yml @@ -0,0 +1,61 @@ +name: Bug Report +description: File a bug report to help us improve +title: '[Bug]: ' +labels: [bug, 'needs triage'] +assignees: [] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: | + Thanks for reporting a bug! Please describe what you were trying to get done. + Tell us what happened, what went wrong. + validations: + required: true + + - type: textarea + id: what-did-you-expect-to-happen + attributes: + label: What did you expect to happen? + description: | + Describe what you expected to happen. + validations: + required: false + + - type: textarea + id: sample-code + attributes: + label: Minimal Complete Verifiable Example + description: | + Minimal, self-contained copy-pastable example that generates the issue if possible. Please be concise with code posted. See guidelines below on how to provide a good bug report: + + - [Minimal Complete Verifiable Examples](https://stackoverflow.com/help/mcve) + - [Craft Minimal Bug Reports](http://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) + + Bug reports that follow these guidelines are easier to diagnose, and so are often handled much more quickly. + This will be automatically formatted into code, so no need for markdown backticks. + render: python + + - type: textarea + id: log-output + attributes: + label: Relevant log output + description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for markdown backticks. + render: python + + - type: textarea + id: extra + attributes: + label: Anything else we need to know? + description: | + Please describe any other information you want to share. + + - type: textarea + id: show-versions + attributes: + label: Environment + description: | + Paste the output of `xr.show_versions()` here + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index 7021fe490aa..00000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - - - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/newfeature.yml b/.github/ISSUE_TEMPLATE/newfeature.yml new file mode 100644 index 00000000000..ec94b0f4b89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/newfeature.yml @@ -0,0 +1,37 @@ +name: Feature Request +description: Suggest an idea for xarray +title: '[FEATURE]: ' +labels: [enhancement] +assignees: [] +body: + - type: textarea + id: description + attributes: + label: Is your feature request related to a problem? + description: | + Please do a quick search of existing issues to make sure that this has not been asked before. + Please provide a clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: | + A clear and concise description of what you want to happen. + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: | + A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + - type: textarea + id: additional-context + attributes: + label: Additional context + description: | + Add any other context about the feature request here. + validations: + required: false From f85ec665940291c9ac368f5e1b8a0711e2d9952d Mon Sep 17 00:00:00 2001 From: Cindy Chiao <6431831+tcchiao@users.noreply.github.com> Date: Wed, 29 Dec 2021 08:54:17 -0800 Subject: [PATCH 10/32] Fix dataarray determination in map_blocks (#6089) Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> Co-authored-by: dcherian --- doc/whats-new.rst | 3 ++- xarray/core/parallel.py | 4 ++-- xarray/tests/test_dask.py | 13 +++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index f991a4e2a89..2572651415d 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -37,7 +37,8 @@ Deprecations Bug fixes ~~~~~~~~~ - +- Fix applying function with non-xarray arguments using :py:func:`xr.map_blocks`. + By `Cindy Chiao `_. Documentation ~~~~~~~~~~~~~ diff --git a/xarray/core/parallel.py b/xarray/core/parallel.py index f20256346da..aad1d285377 100644 --- a/xarray/core/parallel.py +++ b/xarray/core/parallel.py @@ -353,8 +353,8 @@ def _wrapper( # all xarray objects must be aligned. This is consistent with apply_ufunc. aligned = align(*xarray_objs, join="exact") xarray_objs = tuple( - dataarray_to_dataset(arg) if is_da else arg - for is_da, arg in zip(is_array, aligned) + dataarray_to_dataset(arg) if isinstance(arg, DataArray) else arg + for arg in aligned ) _, npargs = unzip( diff --git a/xarray/tests/test_dask.py b/xarray/tests/test_dask.py index 59f15764eb1..48432f319b2 100644 --- a/xarray/tests/test_dask.py +++ b/xarray/tests/test_dask.py @@ -1172,6 +1172,19 @@ def func(obj): assert_identical(actual, expected) +@pytest.mark.parametrize("obj", [make_da(), make_ds()]) +def test_map_blocks_mixed_type_inputs(obj): + def func(obj1, non_xarray_input, obj2): + result = obj1 + obj1.x + 5 * obj1.y + return result + + with raise_if_dask_computes(): + actual = xr.map_blocks(func, obj, args=["non_xarray_input", obj]) + expected = func(obj, "non_xarray_input", obj) + assert_chunks_equal(expected.chunk(), actual) + assert_identical(actual, expected) + + @pytest.mark.parametrize("obj", [make_da(), make_ds()]) def test_map_blocks_convert_args_to_list(obj): expected = obj + 10 From b14e2d8400da5c036f1ebb5486939f7f587b9f27 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 30 Dec 2021 17:54:10 -0500 Subject: [PATCH 11/32] Calendar utilities (#5233) * dt.calendar and date_range * Migrate calendar utils from xclim | add dt.calendar * upd whats new * skip calendar tests with no cftime * add requires cftime 1.1.0 * import date_ranges in main * Apply suggestions from code review Co-authored-by: Mathias Hauser * Add docs - use already existing is np datetime func * update from suggestions * Apply suggestions from code review Co-authored-by: Spencer Clark * Modifications following review * Add DataArray and Dataset methods * use proper type annotation * Apply suggestions from code review Co-authored-by: Spencer Clark * some more modifications after review * Apply suggestions from code review The code will break with this commit. Variable renaming to be done throughout all functions. Co-authored-by: Spencer Clark * Finish applying suggestions from review * Put back missing @require_cftime * Apply suggestions from code review Co-authored-by: Spencer Clark * Add tests - few fixes * wrap docstrings * Change way of importing/testing for cftime * Upd the weather-climate doc page * fix doc examples * Neat docs * fix in tests after review * Apply suggestions from code review Co-authored-by: Spencer Clark * Better explain missing in notes - copy changes to obj methods * Apply suggestions from code review Co-authored-by: Spencer Clark * Remove unused import Co-authored-by: Mathias Hauser Co-authored-by: Spencer Clark Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- doc/api-hidden.rst | 1 + doc/api.rst | 7 + doc/user-guide/weather-climate.rst | 45 ++-- doc/whats-new.rst | 2 + xarray/__init__.py | 4 +- xarray/coding/calendar_ops.py | 341 ++++++++++++++++++++++++++++ xarray/coding/cftime_offsets.py | 193 +++++++++++++++- xarray/coding/times.py | 166 +++++++++++++- xarray/core/accessor_dt.py | 10 + xarray/core/dataarray.py | 156 +++++++++++++ xarray/core/dataset.py | 157 ++++++++++++- xarray/tests/test_accessor_dt.py | 50 ++++ xarray/tests/test_calendar_ops.py | 246 ++++++++++++++++++++ xarray/tests/test_cftime_offsets.py | 112 ++++++++- xarray/tests/test_coding_times.py | 19 ++ 15 files changed, 1468 insertions(+), 41 deletions(-) create mode 100644 xarray/coding/calendar_ops.py create mode 100644 xarray/tests/test_calendar_ops.py diff --git a/doc/api-hidden.rst b/doc/api-hidden.rst index a6681715a3e..8ed9e47be01 100644 --- a/doc/api-hidden.rst +++ b/doc/api-hidden.rst @@ -77,6 +77,7 @@ core.accessor_dt.DatetimeAccessor.floor core.accessor_dt.DatetimeAccessor.round core.accessor_dt.DatetimeAccessor.strftime + core.accessor_dt.DatetimeAccessor.calendar core.accessor_dt.DatetimeAccessor.date core.accessor_dt.DatetimeAccessor.day core.accessor_dt.DatetimeAccessor.dayofweek diff --git a/doc/api.rst b/doc/api.rst index ef2694ea661..b552bc6b4d2 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -109,6 +109,8 @@ Dataset contents Dataset.drop_dims Dataset.set_coords Dataset.reset_coords + Dataset.convert_calendar + Dataset.interp_calendar Dataset.get_index Comparisons @@ -308,6 +310,8 @@ DataArray contents DataArray.drop_duplicates DataArray.reset_coords DataArray.copy + DataArray.convert_calendar + DataArray.interp_calendar DataArray.get_index DataArray.astype DataArray.item @@ -526,6 +530,7 @@ Datetimelike properties DataArray.dt.season DataArray.dt.time DataArray.dt.date + DataArray.dt.calendar DataArray.dt.is_month_start DataArray.dt.is_month_end DataArray.dt.is_quarter_end @@ -1064,6 +1069,8 @@ Creating custom indexes :toctree: generated/ cftime_range + date_range + date_range_like Faceting -------- diff --git a/doc/user-guide/weather-climate.rst b/doc/user-guide/weather-climate.rst index e20bd510df1..893e7b50429 100644 --- a/doc/user-guide/weather-climate.rst +++ b/doc/user-guide/weather-climate.rst @@ -127,6 +127,23 @@ using the same formatting as the standard `datetime.strftime`_ convention . dates.strftime("%c") da["time"].dt.strftime("%Y%m%d") +Conversion between non-standard calendar and to/from pandas DatetimeIndexes is +facilitated with the :py:meth:`xarray.Dataset.convert_calendar` method (also available as +:py:meth:`xarray.DataArray.convert_calendar`). Here, like elsewhere in xarray, the ``use_cftime`` +argument controls which datetime backend is used in the output. The default (``None``) is to +use `pandas` when possible, i.e. when the calendar is standard and dates are within 1678 and 2262. + +.. ipython:: python + + dates = xr.cftime_range(start="2001", periods=24, freq="MS", calendar="noleap") + da_nl = xr.DataArray(np.arange(24), coords=[dates], dims=["time"], name="foo") + da_std = da.convert_calendar("standard", use_cftime=True) + +The data is unchanged, only the timestamps are modified. Further options are implemented +for the special ``"360_day"`` calendar and for handling missing dates. There is also +:py:meth:`xarray.Dataset.interp_calendar` (and :py:meth:`xarray.DataArray.interp_calendar`) +for `interpolating` data between calendars. + For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports: - `Partial datetime string indexing`_: @@ -150,7 +167,8 @@ For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports: - Access of basic datetime components via the ``dt`` accessor (in this case just "year", "month", "day", "hour", "minute", "second", "microsecond", - "season", "dayofyear", "dayofweek", and "days_in_month"): + "season", "dayofyear", "dayofweek", and "days_in_month") with the addition + of "calendar", absent from pandas: .. ipython:: python @@ -160,6 +178,7 @@ For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports: da.time.dt.dayofyear da.time.dt.dayofweek da.time.dt.days_in_month + da.time.dt.calendar - Rounding of datetimes to fixed frequencies via the ``dt`` accessor: @@ -214,30 +233,6 @@ For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports: da.resample(time="81T", closed="right", label="right", base=3).mean() -.. note:: - - - For some use-cases it may still be useful to convert from - a :py:class:`~xarray.CFTimeIndex` to a :py:class:`pandas.DatetimeIndex`, - despite the difference in calendar types. The recommended way of doing this - is to use the built-in :py:meth:`~xarray.CFTimeIndex.to_datetimeindex` - method: - - .. ipython:: python - :okwarning: - - modern_times = xr.cftime_range("2000", periods=24, freq="MS", calendar="noleap") - da = xr.DataArray(range(24), [("time", modern_times)]) - da - datetimeindex = da.indexes["time"].to_datetimeindex() - da["time"] = datetimeindex - - However in this case one should use caution to only perform operations which - do not depend on differences between dates (e.g. differentiation, - interpolation, or upsampling with resample), as these could introduce subtle - and silent errors due to the difference in calendar types between the dates - encoded in your data and the dates stored in memory. - .. _Timestamp-valid range: https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#timestamp-limitations .. _ISO 8601 standard: https://en.wikipedia.org/wiki/ISO_8601 .. _partial datetime string indexing: https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#partial-string-indexing diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 2572651415d..cbc97250ef9 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -156,6 +156,8 @@ New Features - Added ``storage_options`` argument to :py:meth:`to_zarr` (:issue:`5601`, :pull:`5615`). By `Ray Bell `_, `Zachary Blackwood `_ and `Nathan Lis `_. +- Added calendar utilities :py:func:`DataArray.convert_calendar`, :py:func:`DataArray.interp_calendar`, :py:func:`date_range`, :py:func:`date_range_like` and :py:attr:`DataArray.dt.calendar` (:issue:`5155`, :pull:`5233`). + By `Pascal Bourgault `_. - Histogram plots are set with a title displaying the scalar coords if any, similarly to the other plots (:issue:`5791`, :pull:`5792`). By `Maxime Liquet `_. - Slice plots display the coords units in the same way as x/y/colorbar labels (:pull:`5847`). diff --git a/xarray/__init__.py b/xarray/__init__.py index 81ab9f388a8..aa9739d3d35 100644 --- a/xarray/__init__.py +++ b/xarray/__init__.py @@ -9,7 +9,7 @@ ) from .backends.rasterio_ import open_rasterio from .backends.zarr import open_zarr -from .coding.cftime_offsets import cftime_range +from .coding.cftime_offsets import cftime_range, date_range, date_range_like from .coding.cftimeindex import CFTimeIndex from .coding.frequencies import infer_freq from .conventions import SerializationWarning, decode_cf @@ -65,6 +65,8 @@ "combine_by_coords", "combine_nested", "concat", + "date_range", + "date_range_like", "decode_cf", "dot", "cov", diff --git a/xarray/coding/calendar_ops.py b/xarray/coding/calendar_ops.py new file mode 100644 index 00000000000..7b973c8d7ab --- /dev/null +++ b/xarray/coding/calendar_ops.py @@ -0,0 +1,341 @@ +import numpy as np +import pandas as pd + +from ..core.common import _contains_datetime_like_objects, is_np_datetime_like +from .cftime_offsets import date_range_like, get_date_type +from .cftimeindex import CFTimeIndex +from .times import _should_cftime_be_used, convert_times + +try: + import cftime +except ImportError: + cftime = None + + +_CALENDARS_WITHOUT_YEAR_ZERO = [ + "gregorian", + "proleptic_gregorian", + "julian", + "standard", +] + + +def _days_in_year(year, calendar, use_cftime=True): + """Return the number of days in the input year according to the input calendar.""" + date_type = get_date_type(calendar, use_cftime=use_cftime) + if year == -1 and calendar in _CALENDARS_WITHOUT_YEAR_ZERO: + difference = date_type(year + 2, 1, 1) - date_type(year, 1, 1) + else: + difference = date_type(year + 1, 1, 1) - date_type(year, 1, 1) + return difference.days + + +def convert_calendar( + obj, + calendar, + dim="time", + align_on=None, + missing=None, + use_cftime=None, +): + """Transform a time-indexed Dataset or DataArray to one that uses another calendar. + + This function only converts the individual timestamps; it does not modify any + data except in dropping invalid/surplus dates, or inserting values for missing dates. + + If the source and target calendars are both from a standard type, only the + type of the time array is modified. When converting to a calendar with a + leap year from to a calendar without a leap year, the 29th of February will + be removed from the array. In the other direction the 29th of February will + be missing in the output, unless `missing` is specified, in which case that + value is inserted. For conversions involving the `360_day` calendar, see Notes. + + This method is safe to use with sub-daily data as it doesn't touch the time + part of the timestamps. + + Parameters + ---------- + obj : DataArray or Dataset + Input DataArray or Dataset with a time coordinate of a valid dtype + (:py:class:`numpy.datetime64` or :py:class:`cftime.datetime`). + calendar : str + The target calendar name. + dim : str + Name of the time coordinate in the input DataArray or Dataset. + align_on : {None, 'date', 'year'} + Must be specified when either the source or target is a `"360_day"` + calendar; ignored otherwise. See Notes. + missing : any, optional + By default, i.e. if the value is None, this method will simply attempt + to convert the dates in the source calendar to the same dates in the + target calendar, and drop any of those that are not possible to + represent. If a value is provided, a new time coordinate will be + created in the target calendar with the same frequency as the original + time coordinate; for any dates that are not present in the source, the + data will be filled with this value. Note that using this mode requires + that the source data have an inferable frequency; for more information + see :py:func:`xarray.infer_freq`. For certain frequency, source, and + target calendar combinations, this could result in many missing values, see notes. + use_cftime : bool, optional + Whether to use cftime objects in the output, only used if `calendar` is + one of {"proleptic_gregorian", "gregorian" or "standard"}. + If True, the new time axis uses cftime objects. + If None (default), it uses :py:class:`numpy.datetime64` values if the date + range permits it, and :py:class:`cftime.datetime` objects if not. + If False, it uses :py:class:`numpy.datetime64` or fails. + + Returns + ------- + Copy of source with the time coordinate converted to the target calendar. + If `missing` was None (default), invalid dates in the new calendar are + dropped, but missing dates are not inserted. + If `missing` was given, the new data is reindexed to have a time axis + with the same frequency as the source, but in the new calendar; any + missing datapoints are filled with `missing`. + + Notes + ----- + Passing a value to `missing` is only usable if the source's time coordinate as an + inferrable frequencies (see :py:func:`~xarray.infer_freq`) and is only appropriate + if the target coordinate, generated from this frequency, has dates equivalent to the + source. It is usually **not** appropriate to use this mode with: + + - Period-end frequencies: 'A', 'Y', 'Q' or 'M', in opposition to 'AS' 'YS', 'QS' and 'MS' + - Sub-monthly frequencies that do not divide a day evenly: 'W', 'nD' where `n != 1` + or 'mH' where 24 % m != 0). + + If one of the source or target calendars is `"360_day"`, `align_on` must + be specified and two options are offered. + + "year" + The dates are translated according to their relative position in the year, + ignoring their original month and day information, meaning that the + missing/surplus days are added/removed at regular intervals. + + From a `360_day` to a standard calendar, the output will be missing the + following dates (day of year in parentheses): + To a leap year: + January 31st (31), March 31st (91), June 1st (153), July 31st (213), + September 31st (275) and November 30th (335). + To a non-leap year: + February 6th (36), April 19th (109), July 2nd (183), + September 12th (255), November 25th (329). + + From a standard calendar to a `"360_day"`, the following dates in the + source array will be dropped: + From a leap year: + January 31st (31), April 1st (92), June 1st (153), August 1st (214), + September 31st (275), December 1st (336) + From a non-leap year: + February 6th (37), April 20th (110), July 2nd (183), + September 13th (256), November 25th (329) + + This option is best used on daily and subdaily data. + + "date" + The month/day information is conserved and invalid dates are dropped + from the output. This means that when converting from a `"360_day"` to a + standard calendar, all 31sts (Jan, March, May, July, August, October and + December) will be missing as there is no equivalent dates in the + `"360_day"` calendar and the 29th (on non-leap years) and 30th of February + will be dropped as there are no equivalent dates in a standard calendar. + + This option is best used with data on a frequency coarser than daily. + """ + from ..core.dataarray import DataArray + + time = obj[dim] + if not _contains_datetime_like_objects(time): + raise ValueError(f"Coordinate {dim} must contain datetime objects.") + + use_cftime = _should_cftime_be_used(time, calendar, use_cftime) + + source_calendar = time.dt.calendar + # Do nothing if request calendar is the same as the source + # AND source is np XOR use_cftime + if source_calendar == calendar and is_np_datetime_like(time.dtype) ^ use_cftime: + return obj + + if (time.dt.year == 0).any() and calendar in _CALENDARS_WITHOUT_YEAR_ZERO: + raise ValueError( + f"Source time coordinate contains dates with year 0, which is not supported by target calendar {calendar}." + ) + + if (source_calendar == "360_day" or calendar == "360_day") and align_on is None: + raise ValueError( + "Argument `align_on` must be specified with either 'date' or " + "'year' when converting to or from a '360_day' calendar." + ) + + if source_calendar != "360_day" and calendar != "360_day": + align_on = "date" + + out = obj.copy() + + if align_on == "year": + # Special case for conversion involving 360_day calendar + # Instead of translating dates directly, this tries to keep the position within a year similar. + + new_doy = time.groupby(f"{dim}.year").map( + _interpolate_day_of_year, target_calendar=calendar, use_cftime=use_cftime + ) + + # Convert the source datetimes, but override the day of year with our new day of years. + out[dim] = DataArray( + [ + _convert_to_new_calendar_with_new_day_of_year( + date, newdoy, calendar, use_cftime + ) + for date, newdoy in zip(time.variable._data.array, new_doy) + ], + dims=(dim,), + name=dim, + ) + # Remove duplicate timestamps, happens when reducing the number of days + out = out.isel({dim: np.unique(out[dim], return_index=True)[1]}) + elif align_on == "date": + new_times = convert_times( + time.data, + get_date_type(calendar, use_cftime=use_cftime), + raise_on_invalid=False, + ) + out[dim] = new_times + + # Remove NaN that where put on invalid dates in target calendar + out = out.where(out[dim].notnull(), drop=True) + + if missing is not None: + time_target = date_range_like(time, calendar=calendar, use_cftime=use_cftime) + out = out.reindex({dim: time_target}, fill_value=missing) + + # Copy attrs but remove `calendar` if still present. + out[dim].attrs.update(time.attrs) + out[dim].attrs.pop("calendar", None) + return out + + +def _interpolate_day_of_year(time, target_calendar, use_cftime): + """Returns the nearest day in the target calendar of the corresponding + "decimal year" in the source calendar. + """ + year = int(time.dt.year[0]) + source_calendar = time.dt.calendar + return np.round( + _days_in_year(year, target_calendar, use_cftime) + * time.dt.dayofyear + / _days_in_year(year, source_calendar, use_cftime) + ).astype(int) + + +def _convert_to_new_calendar_with_new_day_of_year( + date, day_of_year, calendar, use_cftime +): + """Convert a datetime object to another calendar with a new day of year. + + Redefines the day of year (and thus ignores the month and day information + from the source datetime). + Nanosecond information is lost as cftime.datetime doesn't support it. + """ + new_date = cftime.num2date( + day_of_year - 1, + f"days since {date.year}-01-01", + calendar=calendar if use_cftime else "standard", + ) + try: + return get_date_type(calendar, use_cftime)( + date.year, + new_date.month, + new_date.day, + date.hour, + date.minute, + date.second, + date.microsecond, + ) + except ValueError: + return np.nan + + +def _datetime_to_decimal_year(times, dim="time", calendar=None): + """Convert a datetime DataArray to decimal years according to its calendar or the given one. + + The decimal year of a timestamp is its year plus its sub-year component + converted to the fraction of its year. + Ex: '2000-03-01 12:00' is 2000.1653 in a standard calendar, + 2000.16301 in a "noleap" or 2000.16806 in a "360_day". + """ + from ..core.dataarray import DataArray + + calendar = calendar or times.dt.calendar + + if is_np_datetime_like(times.dtype): + times = times.copy(data=convert_times(times.values, get_date_type("standard"))) + + def _make_index(time): + year = int(time.dt.year[0]) + doys = cftime.date2num(time, f"days since {year:04d}-01-01", calendar=calendar) + return DataArray( + year + doys / _days_in_year(year, calendar), + dims=(dim,), + coords=time.coords, + name=dim, + ) + + return times.groupby(f"{dim}.year").map(_make_index) + + +def interp_calendar(source, target, dim="time"): + """Interpolates a DataArray or Dataset indexed by a time coordinate to + another calendar based on decimal year measure. + + Each timestamp in `source` and `target` are first converted to their decimal + year equivalent then `source` is interpolated on the target coordinate. + The decimal year of a timestamp is its year plus its sub-year component + converted to the fraction of its year. For example "2000-03-01 12:00" is + 2000.1653 in a standard calendar or 2000.16301 in a `"noleap"` calendar. + + This method should only be used when the time (HH:MM:SS) information of + time coordinate is not important. + + Parameters + ---------- + source: DataArray or Dataset + The source data to interpolate; must have a time coordinate of a valid + dtype (:py:class:`numpy.datetime64` or :py:class:`cftime.datetime` objects) + target: DataArray, DatetimeIndex, or CFTimeIndex + The target time coordinate of a valid dtype (np.datetime64 or cftime objects) + dim : str + The time coordinate name. + + Return + ------ + DataArray or Dataset + The source interpolated on the decimal years of target, + """ + from ..core.dataarray import DataArray + + if isinstance(target, (pd.DatetimeIndex, CFTimeIndex)): + target = DataArray(target, dims=(dim,), name=dim) + + if not _contains_datetime_like_objects( + source[dim] + ) or not _contains_datetime_like_objects(target): + raise ValueError( + f"Both 'source.{dim}' and 'target' must contain datetime objects." + ) + + source_calendar = source[dim].dt.calendar + target_calendar = target.dt.calendar + + if ( + source[dim].time.dt.year == 0 + ).any() and target_calendar in _CALENDARS_WITHOUT_YEAR_ZERO: + raise ValueError( + f"Source time coordinate contains dates with year 0, which is not supported by target calendar {target_calendar}." + ) + + out = source.copy() + out[dim] = _datetime_to_decimal_year(source[dim], dim=dim, calendar=source_calendar) + target_idx = _datetime_to_decimal_year(target, dim=dim, calendar=target_calendar) + out = out.interp(**{dim: target_idx}) + out[dim] = target + return out diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index 729f15bbd50..2db6d4e8097 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -41,15 +41,22 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import re -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import ClassVar, Optional import numpy as np +import pandas as pd +from ..core.common import _contains_datetime_like_objects, is_np_datetime_like from ..core.pdcompat import count_not_none from .cftimeindex import CFTimeIndex, _parse_iso8601_with_reso -from .times import format_cftime_datetime +from .times import ( + _is_standard_calendar, + _should_cftime_be_used, + convert_time_or_go_back, + format_cftime_datetime, +) try: import cftime @@ -57,11 +64,14 @@ cftime = None -def get_date_type(calendar): +def get_date_type(calendar, use_cftime=True): """Return the cftime date type for a given calendar name.""" if cftime is None: raise ImportError("cftime is required for dates with non-standard calendars") else: + if _is_standard_calendar(calendar) and not use_cftime: + return pd.Timestamp + calendars = { "noleap": cftime.DatetimeNoLeap, "360_day": cftime.Datetime360Day, @@ -700,6 +710,8 @@ def to_cftime_datetime(date_str_or_date, calendar=None): return date elif isinstance(date_str_or_date, cftime.datetime): return date_str_or_date + elif isinstance(date_str_or_date, (datetime, pd.Timestamp)): + return cftime.DatetimeProlepticGregorian(*date_str_or_date.timetuple()) else: raise TypeError( "date_str_or_date must be a string or a " @@ -1009,3 +1021,178 @@ def cftime_range( dates = dates[:-1] return CFTimeIndex(dates, name=name) + + +def date_range( + start=None, + end=None, + periods=None, + freq="D", + tz=None, + normalize=False, + name=None, + closed=None, + calendar="standard", + use_cftime=None, +): + """Return a fixed frequency datetime index. + + The type (:py:class:`xarray.CFTimeIndex` or :py:class:`pandas.DatetimeIndex`) + of the returned index depends on the requested calendar and on `use_cftime`. + + Parameters + ---------- + start : str or datetime-like, optional + Left bound for generating dates. + end : str or datetime-like, optional + Right bound for generating dates. + periods : int, optional + Number of periods to generate. + freq : str or None, default: "D" + Frequency strings can have multiples, e.g. "5H". + tz : str or tzinfo, optional + Time zone name for returning localized DatetimeIndex, for example + 'Asia/Hong_Kong'. By default, the resulting DatetimeIndex is + timezone-naive. Only valid with pandas DatetimeIndex. + normalize : bool, default: False + Normalize start/end dates to midnight before generating date range. + name : str, default: None + Name of the resulting index + closed : {"left", "right"} or None, default: None + Make the interval closed with respect to the given frequency to the + "left", "right", or both sides (None). + calendar : str, default: "standard" + Calendar type for the datetimes. + use_cftime : boolean, optional + If True, always return a CFTimeIndex. + If False, return a pd.DatetimeIndex if possible or raise a ValueError. + If None (default), return a pd.DatetimeIndex if possible, + otherwise return a CFTimeIndex. Defaults to False if `tz` is not None. + + Returns + ------- + CFTimeIndex or pd.DatetimeIndex + + See also + -------- + pandas.date_range + cftime_range + date_range_like + """ + from .times import _is_standard_calendar + + if tz is not None: + use_cftime = False + + if _is_standard_calendar(calendar) and use_cftime is not True: + try: + return pd.date_range( + start=start, + end=end, + periods=periods, + freq=freq, + tz=tz, + normalize=normalize, + name=name, + closed=closed, + ) + except pd.errors.OutOfBoundsDatetime as err: + if use_cftime is False: + raise ValueError( + "Date range is invalid for pandas DatetimeIndex, try using `use_cftime=True`." + ) from err + elif use_cftime is False: + raise ValueError( + f"Invalid calendar {calendar} for pandas DatetimeIndex, try using `use_cftime=True`." + ) + + return cftime_range( + start=start, + end=end, + periods=periods, + freq=freq, + normalize=normalize, + name=name, + closed=closed, + calendar=calendar, + ) + + +def date_range_like(source, calendar, use_cftime=None): + """Generate a datetime array with the same frequency, start and end as + another one, but in a different calendar. + + Parameters + ---------- + source : DataArray, CFTimeIndex, or pd.DatetimeIndex + 1D datetime array + calendar : str + New calendar name. + use_cftime : bool, optional + If True, the output uses :py:class:`cftime.datetime` objects. + If None (default), :py:class:`numpy.datetime64` values are used if possible. + If False, :py:class:`numpy.datetime64` values are used or an error is raised. + + Returns + ------- + DataArray + 1D datetime coordinate with the same start, end and frequency as the + source, but in the new calendar. The start date is assumed to exist in + the target calendar. If the end date doesn't exist, the code tries 1 + and 2 calendar days before. There is a special case when the source time + series is daily or coarser and the end of the input range is on the + last day of the month. Then the output range will also end on the last + day of the month in the new calendar. + """ + from ..core.dataarray import DataArray + from .frequencies import infer_freq + + if not isinstance(source, (pd.DatetimeIndex, CFTimeIndex)) and ( + isinstance(source, DataArray) + and (source.ndim != 1) + or not _contains_datetime_like_objects(source) + ): + raise ValueError( + "'source' must be a 1D array of datetime objects for inferring its range." + ) + + freq = infer_freq(source) + if freq is None: + raise ValueError( + "`date_range_like` was unable to generate a range as the source frequency was not inferrable." + ) + + use_cftime = _should_cftime_be_used(source, calendar, use_cftime) + + source_start = source.values.min() + source_end = source.values.max() + if is_np_datetime_like(source.dtype): + # We want to use datetime fields (datetime64 object don't have them) + source_calendar = "standard" + source_start = pd.Timestamp(source_start) + source_end = pd.Timestamp(source_end) + else: + if isinstance(source, CFTimeIndex): + source_calendar = source.calendar + else: # DataArray + source_calendar = source.dt.calendar + + if calendar == source_calendar and is_np_datetime_like(source.dtype) ^ use_cftime: + return source + + date_type = get_date_type(calendar, use_cftime) + start = convert_time_or_go_back(source_start, date_type) + end = convert_time_or_go_back(source_end, date_type) + + # For the cases where the source ends on the end of the month, we expect the same in the new calendar. + if source_end.day == source_end.daysinmonth and isinstance( + to_offset(freq), (YearEnd, QuarterEnd, MonthEnd, Day) + ): + end = end.replace(day=end.daysinmonth) + + return date_range( + start=start.isoformat(), + end=end.isoformat(), + freq=freq, + calendar=calendar, + ) diff --git a/xarray/coding/times.py b/xarray/coding/times.py index 7d532f8fc38..c89b0c100cd 100644 --- a/xarray/coding/times.py +++ b/xarray/coding/times.py @@ -8,8 +8,9 @@ from pandas.errors import OutOfBoundsDatetime from ..core import indexing -from ..core.common import contains_cftime_datetimes +from ..core.common import contains_cftime_datetimes, is_np_datetime_like from ..core.formatting import first_n_items, format_timestamp, last_item +from ..core.pycompat import is_duck_dask_array from ..core.variable import Variable from .variables import ( SerializationWarning, @@ -76,6 +77,26 @@ def _is_standard_calendar(calendar): return calendar.lower() in _STANDARD_CALENDARS +def _is_numpy_compatible_time_range(times): + if is_np_datetime_like(times.dtype): + return True + # times array contains cftime objects + times = np.asarray(times) + tmin = times.min() + tmax = times.max() + try: + convert_time_or_go_back(tmin, pd.Timestamp) + convert_time_or_go_back(tmax, pd.Timestamp) + except pd.errors.OutOfBoundsDatetime: + return False + except ValueError as err: + if err.args[0] == "year 0 is out of range": + return False + raise + else: + return True + + def _netcdf_to_numpy_timeunit(units): units = units.lower() if not units.endswith("s"): @@ -322,10 +343,21 @@ def _infer_time_units_from_diff(unique_timedeltas): def infer_calendar_name(dates): """Given an array of datetimes, infer the CF calendar name""" - if np.asarray(dates).dtype == "datetime64[ns]": + if is_np_datetime_like(dates.dtype): return "proleptic_gregorian" - else: - return np.asarray(dates).ravel()[0].calendar + elif dates.dtype == np.dtype("O") and dates.size > 0: + # Logic copied from core.common.contains_cftime_datetimes. + if cftime is not None: + sample = dates.ravel()[0] + if is_duck_dask_array(sample): + sample = sample.compute() + if isinstance(sample, np.ndarray): + sample = sample.item() + if isinstance(sample, cftime.datetime): + return sample.calendar + + # Error raise if dtype is neither datetime or "O", if cftime is not importable, and if element of 'O' dtype is not cftime. + raise ValueError("Array does not contain datetime objects.") def infer_datetime_units(dates): @@ -373,9 +405,12 @@ def infer_timedelta_units(deltas): return _infer_time_units_from_diff(unique_timedeltas) -def cftime_to_nptime(times): +def cftime_to_nptime(times, raise_on_invalid=True): """Given an array of cftime.datetime objects, return an array of - numpy.datetime64 objects of the same size""" + numpy.datetime64 objects of the same size + + If raise_on_invalid is True (default), invalid dates trigger a ValueError. + Otherwise, the invalid element is replaced by np.NaT.""" times = np.asarray(times) new = np.empty(times.shape, dtype="M8[ns]") for i, t in np.ndenumerate(times): @@ -388,14 +423,125 @@ def cftime_to_nptime(times): t.year, t.month, t.day, t.hour, t.minute, t.second, t.microsecond ) except ValueError as e: - raise ValueError( - "Cannot convert date {} to a date in the " - "standard calendar. Reason: {}.".format(t, e) - ) + if raise_on_invalid: + raise ValueError( + "Cannot convert date {} to a date in the " + "standard calendar. Reason: {}.".format(t, e) + ) + else: + dt = "NaT" new[i] = np.datetime64(dt) return new +def convert_times(times, date_type, raise_on_invalid=True): + """Given an array of datetimes, return the same dates in another cftime or numpy date type. + + Useful to convert between calendars in numpy and cftime or between cftime calendars. + + If raise_on_valid is True (default), invalid dates trigger a ValueError. + Otherwise, the invalid element is replaced by np.NaN for cftime types and np.NaT for np.datetime64. + """ + if date_type in (pd.Timestamp, np.datetime64) and not is_np_datetime_like( + times.dtype + ): + return cftime_to_nptime(times, raise_on_invalid=raise_on_invalid) + if is_np_datetime_like(times.dtype): + # Convert datetime64 objects to Timestamps since those have year, month, day, etc. attributes + times = pd.DatetimeIndex(times) + new = np.empty(times.shape, dtype="O") + for i, t in enumerate(times): + try: + dt = date_type( + t.year, t.month, t.day, t.hour, t.minute, t.second, t.microsecond + ) + except ValueError as e: + if raise_on_invalid: + raise ValueError( + "Cannot convert date {} to a date in the " + "{} calendar. Reason: {}.".format( + t, date_type(2000, 1, 1).calendar, e + ) + ) + else: + dt = np.NaN + + new[i] = dt + return new + + +def convert_time_or_go_back(date, date_type): + """Convert a single date to a new date_type (cftime.datetime or pd.Timestamp). + + If the new date is invalid, it goes back a day and tries again. If it is still + invalid, goes back a second day. + + This is meant to convert end-of-month dates into a new calendar. + """ + try: + return date_type( + date.year, + date.month, + date.day, + date.hour, + date.minute, + date.second, + date.microsecond, + ) + except OutOfBoundsDatetime: + raise + except ValueError: + # Day is invalid, happens at the end of months, try again the day before + try: + return date_type( + date.year, + date.month, + date.day - 1, + date.hour, + date.minute, + date.second, + date.microsecond, + ) + except ValueError: + # Still invalid, happens for 360_day to non-leap february. Try again 2 days before date. + return date_type( + date.year, + date.month, + date.day - 2, + date.hour, + date.minute, + date.second, + date.microsecond, + ) + + +def _should_cftime_be_used(source, target_calendar, use_cftime): + """Return whether conversion of the source to the target calendar should + result in a cftime-backed array. + + Source is a 1D datetime array, target_cal a string (calendar name) and + use_cftime is a boolean or None. If use_cftime is None, this returns True + if the source's range and target calendar are convertible to np.datetime64 objects. + """ + # Arguments Checks for target + if use_cftime is not True: + if _is_standard_calendar(target_calendar): + if _is_numpy_compatible_time_range(source): + # Conversion is possible with pandas, force False if it was None + use_cftime = False + elif use_cftime is False: + raise ValueError( + "Source time range is not valid for numpy datetimes. Try using `use_cftime=True`." + ) + elif use_cftime is False: + raise ValueError( + f"Calendar '{target_calendar}' is only valid with cftime. Try using `use_cftime=True`." + ) + else: + use_cftime = True + return use_cftime + + def _cleanup_netcdf_time_units(units): delta, ref_date = _unpack_netcdf_time_units(units) try: diff --git a/xarray/core/accessor_dt.py b/xarray/core/accessor_dt.py index 2cdd467bdf3..2a7b6200d3b 100644 --- a/xarray/core/accessor_dt.py +++ b/xarray/core/accessor_dt.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd +from ..coding.times import infer_calendar_name from .common import ( _contains_datetime_like_objects, is_np_datetime_like, @@ -440,6 +441,15 @@ def weekofyear(self): "is_leap_year", "Boolean indicator if the date belongs to a leap year.", bool ) + @property + def calendar(self): + """The name of the calendar of the dates. + + Only relevant for arrays of :py:class:`cftime.datetime` objects, + returns "proleptic_gregorian" for arrays of :py:class:`numpy.datetime64` values. + """ + return infer_calendar_name(self._obj.data) + class TimedeltaAccessor(Properties): """Access Timedelta fields for DataArrays with Timedelta-like dtypes. diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index 05d06400f2e..0a99d898c3a 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -21,6 +21,8 @@ import numpy as np import pandas as pd +from ..coding.calendar_ops import convert_calendar, interp_calendar +from ..coding.cftimeindex import CFTimeIndex from ..plot.plot import _PlotMethods from ..plot.utils import _get_units_from_attrs from . import ( @@ -4656,6 +4658,160 @@ def drop_duplicates( indexes = {dim: ~self.get_index(dim).duplicated(keep=keep)} return self.isel(indexes) + def convert_calendar( + self, + calendar: str, + dim: str = "time", + align_on: Optional[str] = None, + missing: Optional[Any] = None, + use_cftime: Optional[bool] = None, + ) -> "DataArray": + """Convert the DataArray to another calendar. + + Only converts the individual timestamps, does not modify any data except + in dropping invalid/surplus dates or inserting missing dates. + + If the source and target calendars are either no_leap, all_leap or a + standard type, only the type of the time array is modified. + When converting to a leap year from a non-leap year, the 29th of February + is removed from the array. In the other direction the 29th of February + will be missing in the output, unless `missing` is specified, + in which case that value is inserted. + + For conversions involving `360_day` calendars, see Notes. + + This method is safe to use with sub-daily data as it doesn't touch the + time part of the timestamps. + + Parameters + --------- + calendar : str + The target calendar name. + dim : str + Name of the time coordinate. + align_on : {None, 'date', 'year'} + Must be specified when either source or target is a `360_day` calendar, + ignored otherwise. See Notes. + missing : Optional[any] + By default, i.e. if the value is None, this method will simply attempt + to convert the dates in the source calendar to the same dates in the + target calendar, and drop any of those that are not possible to + represent. If a value is provided, a new time coordinate will be + created in the target calendar with the same frequency as the original + time coordinate; for any dates that are not present in the source, the + data will be filled with this value. Note that using this mode requires + that the source data have an inferable frequency; for more information + see :py:func:`xarray.infer_freq`. For certain frequency, source, and + target calendar combinations, this could result in many missing values, see notes. + use_cftime : boolean, optional + Whether to use cftime objects in the output, only used if `calendar` + is one of {"proleptic_gregorian", "gregorian" or "standard"}. + If True, the new time axis uses cftime objects. + If None (default), it uses :py:class:`numpy.datetime64` values if the + date range permits it, and :py:class:`cftime.datetime` objects if not. + If False, it uses :py:class:`numpy.datetime64` or fails. + + Returns + ------- + DataArray + Copy of the dataarray with the time coordinate converted to the + target calendar. If 'missing' was None (default), invalid dates in + the new calendar are dropped, but missing dates are not inserted. + If `missing` was given, the new data is reindexed to have a time axis + with the same frequency as the source, but in the new calendar; any + missing datapoints are filled with `missing`. + + Notes + ----- + Passing a value to `missing` is only usable if the source's time coordinate as an + inferrable frequencies (see :py:func:`~xarray.infer_freq`) and is only appropriate + if the target coordinate, generated from this frequency, has dates equivalent to the + source. It is usually **not** appropriate to use this mode with: + + - Period-end frequencies : 'A', 'Y', 'Q' or 'M', in opposition to 'AS' 'YS', 'QS' and 'MS' + - Sub-monthly frequencies that do not divide a day evenly : 'W', 'nD' where `N != 1` + or 'mH' where 24 % m != 0). + + If one of the source or target calendars is `"360_day"`, `align_on` must + be specified and two options are offered. + + - "year" + The dates are translated according to their relative position in the year, + ignoring their original month and day information, meaning that the + missing/surplus days are added/removed at regular intervals. + + From a `360_day` to a standard calendar, the output will be missing the + following dates (day of year in parentheses): + + To a leap year: + January 31st (31), March 31st (91), June 1st (153), July 31st (213), + September 31st (275) and November 30th (335). + To a non-leap year: + February 6th (36), April 19th (109), July 2nd (183), + September 12th (255), November 25th (329). + + From a standard calendar to a `"360_day"`, the following dates in the + source array will be dropped: + + From a leap year: + January 31st (31), April 1st (92), June 1st (153), August 1st (214), + September 31st (275), December 1st (336) + From a non-leap year: + February 6th (37), April 20th (110), July 2nd (183), + September 13th (256), November 25th (329) + + This option is best used on daily and subdaily data. + + - "date" + The month/day information is conserved and invalid dates are dropped + from the output. This means that when converting from a `"360_day"` to a + standard calendar, all 31st (Jan, March, May, July, August, October and + December) will be missing as there is no equivalent dates in the + `"360_day"` calendar and the 29th (on non-leap years) and 30th of February + will be dropped as there are no equivalent dates in a standard calendar. + + This option is best used with data on a frequency coarser than daily. + """ + return convert_calendar( + self, + calendar, + dim=dim, + align_on=align_on, + missing=missing, + use_cftime=use_cftime, + ) + + def interp_calendar( + self, + target: Union[pd.DatetimeIndex, CFTimeIndex, "DataArray"], + dim: str = "time", + ) -> "DataArray": + """Interpolates the DataArray to another calendar based on decimal year measure. + + Each timestamp in `source` and `target` are first converted to their decimal + year equivalent then `source` is interpolated on the target coordinate. + The decimal year of a timestamp is its year plus its sub-year component + converted to the fraction of its year. For example "2000-03-01 12:00" is + 2000.1653 in a standard calendar or 2000.16301 in a `"noleap"` calendar. + + This method should only be used when the time (HH:MM:SS) information of + time coordinate is not important. + + Parameters + ---------- + target: DataArray or DatetimeIndex or CFTimeIndex + The target time coordinate of a valid dtype + (np.datetime64 or cftime objects) + dim : str + The time coordinate name. + + Return + ------ + DataArray + The source interpolated on the decimal years of target, + """ + return interp_calendar(self, target, dim=dim) + # this needs to be at the end, or mypy will confuse with `str` # https://mypy.readthedocs.io/en/latest/common_issues.html#dealing-with-conflicting-names str = utils.UncachedAccessor(StringAccessor) diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 3fbc6154c5d..360ffd42d54 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -35,7 +35,8 @@ import xarray as xr -from ..coding.cftimeindex import _parse_array_of_cftime_strings +from ..coding.calendar_ops import convert_calendar, interp_calendar +from ..coding.cftimeindex import CFTimeIndex, _parse_array_of_cftime_strings from ..plot.dataset_plot import _Dataset_PlotMethods from . import ( alignment, @@ -7731,3 +7732,157 @@ def _wrapper(Y, *coords_, **kwargs): result.attrs = self.attrs.copy() return result + + def convert_calendar( + self, + calendar: str, + dim: str = "time", + align_on: Optional[str] = None, + missing: Optional[Any] = None, + use_cftime: Optional[bool] = None, + ) -> "Dataset": + """Convert the Dataset to another calendar. + + Only converts the individual timestamps, does not modify any data except + in dropping invalid/surplus dates or inserting missing dates. + + If the source and target calendars are either no_leap, all_leap or a + standard type, only the type of the time array is modified. + When converting to a leap year from a non-leap year, the 29th of February + is removed from the array. In the other direction the 29th of February + will be missing in the output, unless `missing` is specified, + in which case that value is inserted. + + For conversions involving `360_day` calendars, see Notes. + + This method is safe to use with sub-daily data as it doesn't touch the + time part of the timestamps. + + Parameters + --------- + calendar : str + The target calendar name. + dim : str + Name of the time coordinate. + align_on : {None, 'date', 'year'} + Must be specified when either source or target is a `360_day` calendar, + ignored otherwise. See Notes. + missing : Optional[any] + By default, i.e. if the value is None, this method will simply attempt + to convert the dates in the source calendar to the same dates in the + target calendar, and drop any of those that are not possible to + represent. If a value is provided, a new time coordinate will be + created in the target calendar with the same frequency as the original + time coordinate; for any dates that are not present in the source, the + data will be filled with this value. Note that using this mode requires + that the source data have an inferable frequency; for more information + see :py:func:`xarray.infer_freq`. For certain frequency, source, and + target calendar combinations, this could result in many missing values, see notes. + use_cftime : boolean, optional + Whether to use cftime objects in the output, only used if `calendar` + is one of {"proleptic_gregorian", "gregorian" or "standard"}. + If True, the new time axis uses cftime objects. + If None (default), it uses :py:class:`numpy.datetime64` values if the + date range permits it, and :py:class:`cftime.datetime` objects if not. + If False, it uses :py:class:`numpy.datetime64` or fails. + + Returns + ------- + Dataset + Copy of the dataarray with the time coordinate converted to the + target calendar. If 'missing' was None (default), invalid dates in + the new calendar are dropped, but missing dates are not inserted. + If `missing` was given, the new data is reindexed to have a time axis + with the same frequency as the source, but in the new calendar; any + missing datapoints are filled with `missing`. + + Notes + ----- + Passing a value to `missing` is only usable if the source's time coordinate as an + inferrable frequencies (see :py:func:`~xarray.infer_freq`) and is only appropriate + if the target coordinate, generated from this frequency, has dates equivalent to the + source. It is usually **not** appropriate to use this mode with: + + - Period-end frequencies : 'A', 'Y', 'Q' or 'M', in opposition to 'AS' 'YS', 'QS' and 'MS' + - Sub-monthly frequencies that do not divide a day evenly : 'W', 'nD' where `N != 1` + or 'mH' where 24 % m != 0). + + If one of the source or target calendars is `"360_day"`, `align_on` must + be specified and two options are offered. + + - "year" + The dates are translated according to their relative position in the year, + ignoring their original month and day information, meaning that the + missing/surplus days are added/removed at regular intervals. + + From a `360_day` to a standard calendar, the output will be missing the + following dates (day of year in parentheses): + + To a leap year: + January 31st (31), March 31st (91), June 1st (153), July 31st (213), + September 31st (275) and November 30th (335). + To a non-leap year: + February 6th (36), April 19th (109), July 2nd (183), + September 12th (255), November 25th (329). + + From a standard calendar to a `"360_day"`, the following dates in the + source array will be dropped: + + From a leap year: + January 31st (31), April 1st (92), June 1st (153), August 1st (214), + September 31st (275), December 1st (336) + From a non-leap year: + February 6th (37), April 20th (110), July 2nd (183), + September 13th (256), November 25th (329) + + This option is best used on daily and subdaily data. + + - "date" + The month/day information is conserved and invalid dates are dropped + from the output. This means that when converting from a `"360_day"` to a + standard calendar, all 31st (Jan, March, May, July, August, October and + December) will be missing as there is no equivalent dates in the + `"360_day"` calendar and the 29th (on non-leap years) and 30th of February + will be dropped as there are no equivalent dates in a standard calendar. + + This option is best used with data on a frequency coarser than daily. + """ + return convert_calendar( + self, + calendar, + dim=dim, + align_on=align_on, + missing=missing, + use_cftime=use_cftime, + ) + + def interp_calendar( + self, + target: Union[pd.DatetimeIndex, CFTimeIndex, "DataArray"], + dim: str = "time", + ) -> "Dataset": + """Interpolates the Dataset to another calendar based on decimal year measure. + + Each timestamp in `source` and `target` are first converted to their decimal + year equivalent then `source` is interpolated on the target coordinate. + The decimal year of a timestamp is its year plus its sub-year component + converted to the fraction of its year. For example "2000-03-01 12:00" is + 2000.1653 in a standard calendar or 2000.16301 in a `"noleap"` calendar. + + This method should only be used when the time (HH:MM:SS) information of + time coordinate is not important. + + Parameters + ---------- + target: DataArray or DatetimeIndex or CFTimeIndex + The target time coordinate of a valid dtype + (np.datetime64 or cftime objects) + dim : str + The time coordinate name. + + Return + ------ + DataArray + The source interpolated on the decimal years of target, + """ + return interp_calendar(self, target, dim=dim) diff --git a/xarray/tests/test_accessor_dt.py b/xarray/tests/test_accessor_dt.py index bdfe25b3c5c..b471bd2e267 100644 --- a/xarray/tests/test_accessor_dt.py +++ b/xarray/tests/test_accessor_dt.py @@ -105,6 +105,10 @@ def test_isocalendar(self, field, pandas_field) -> None: actual = self.data.time.dt.isocalendar()[field] assert_equal(expected, actual) + def test_calendar(self) -> None: + cal = self.data.time.dt.calendar + assert cal == "proleptic_gregorian" + def test_strftime(self) -> None: assert ( "2000-01-01 01:00:00" == self.data.time.dt.strftime("%Y-%m-%d %H:%M:%S")[1] @@ -409,6 +413,52 @@ def test_field_access(data, field) -> None: assert_equal(result, expected) +@requires_cftime +def test_calendar_cftime(data) -> None: + expected = data.time.values[0].calendar + assert data.time.dt.calendar == expected + + +@requires_cftime +def test_calendar_cftime_2D(data) -> None: + # 2D np datetime: + data = xr.DataArray( + np.random.randint(1, 1000000, size=(4, 5)).astype(" None: + import dask.array as da + + # 3D lazy dask - np + data = xr.DataArray( + da.random.random_integers(1, 1000000, size=(4, 5, 6)).astype(" None: + from cftime import num2date + + # 3D lazy dask + data = xr.DataArray( + num2date( + np.random.randint(1, 1000000, size=(4, 5, 6)), + "hours since 1970-01-01T00:00", + calendar="noleap", + ), + dims=("x", "y", "z"), + ).chunk() + with raise_if_dask_computes(max_computes=2): + assert data.dt.calendar == "noleap" + + @requires_cftime def test_isocalendar_cftime(data) -> None: diff --git a/xarray/tests/test_calendar_ops.py b/xarray/tests/test_calendar_ops.py new file mode 100644 index 00000000000..8d1ddcf4689 --- /dev/null +++ b/xarray/tests/test_calendar_ops.py @@ -0,0 +1,246 @@ +import numpy as np +import pytest + +from xarray import DataArray, infer_freq +from xarray.coding.calendar_ops import convert_calendar, interp_calendar +from xarray.coding.cftime_offsets import date_range +from xarray.testing import assert_identical + +from . import requires_cftime + +cftime = pytest.importorskip("cftime") + + +@pytest.mark.parametrize( + "source, target, use_cftime, freq", + [ + ("standard", "noleap", None, "D"), + ("noleap", "proleptic_gregorian", True, "D"), + ("noleap", "all_leap", None, "D"), + ("all_leap", "proleptic_gregorian", False, "4H"), + ], +) +def test_convert_calendar(source, target, use_cftime, freq): + src = DataArray( + date_range("2004-01-01", "2004-12-31", freq=freq, calendar=source), + dims=("time",), + name="time", + ) + da_src = DataArray( + np.linspace(0, 1, src.size), dims=("time",), coords={"time": src} + ) + + conv = convert_calendar(da_src, target, use_cftime=use_cftime) + + assert conv.time.dt.calendar == target + + if source != "noleap": + expected_times = date_range( + "2004-01-01", + "2004-12-31", + freq=freq, + use_cftime=use_cftime, + calendar=target, + ) + else: + expected_times_pre_leap = date_range( + "2004-01-01", + "2004-02-28", + freq=freq, + use_cftime=use_cftime, + calendar=target, + ) + expected_times_post_leap = date_range( + "2004-03-01", + "2004-12-31", + freq=freq, + use_cftime=use_cftime, + calendar=target, + ) + expected_times = expected_times_pre_leap.append(expected_times_post_leap) + np.testing.assert_array_equal(conv.time, expected_times) + + +@pytest.mark.parametrize( + "source,target,freq", + [ + ("standard", "360_day", "D"), + ("360_day", "proleptic_gregorian", "D"), + ("proleptic_gregorian", "360_day", "4H"), + ], +) +@pytest.mark.parametrize("align_on", ["date", "year"]) +def test_convert_calendar_360_days(source, target, freq, align_on): + src = DataArray( + date_range("2004-01-01", "2004-12-30", freq=freq, calendar=source), + dims=("time",), + name="time", + ) + da_src = DataArray( + np.linspace(0, 1, src.size), dims=("time",), coords={"time": src} + ) + + conv = convert_calendar(da_src, target, align_on=align_on) + + assert conv.time.dt.calendar == target + + if align_on == "date": + np.testing.assert_array_equal( + conv.time.resample(time="M").last().dt.day, + [30, 29, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + ) + elif target == "360_day": + np.testing.assert_array_equal( + conv.time.resample(time="M").last().dt.day, + [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 29], + ) + else: + np.testing.assert_array_equal( + conv.time.resample(time="M").last().dt.day, + [30, 29, 30, 30, 31, 30, 30, 31, 30, 31, 29, 31], + ) + if source == "360_day" and align_on == "year": + assert conv.size == 360 if freq == "D" else 360 * 4 + else: + assert conv.size == 359 if freq == "D" else 359 * 4 + + +@requires_cftime +@pytest.mark.parametrize( + "source,target,freq", + [ + ("standard", "noleap", "D"), + ("noleap", "proleptic_gregorian", "4H"), + ("noleap", "all_leap", "M"), + ("360_day", "noleap", "D"), + ("noleap", "360_day", "D"), + ], +) +def test_convert_calendar_missing(source, target, freq): + src = DataArray( + date_range( + "2004-01-01", + "2004-12-31" if source != "360_day" else "2004-12-30", + freq=freq, + calendar=source, + ), + dims=("time",), + name="time", + ) + da_src = DataArray( + np.linspace(0, 1, src.size), dims=("time",), coords={"time": src} + ) + out = convert_calendar(da_src, target, missing=np.nan, align_on="date") + assert infer_freq(out.time) == freq + + expected = date_range( + "2004-01-01", + "2004-12-31" if target != "360_day" else "2004-12-30", + freq=freq, + calendar=target, + ) + np.testing.assert_array_equal(out.time, expected) + + if freq != "M": + out_without_missing = convert_calendar(da_src, target, align_on="date") + expected_nan = out.isel(time=~out.time.isin(out_without_missing.time)) + assert expected_nan.isnull().all() + + expected_not_nan = out.sel(time=out_without_missing.time) + assert_identical(expected_not_nan, out_without_missing) + + +@requires_cftime +def test_convert_calendar_errors(): + src_nl = DataArray( + date_range("0000-01-01", "0000-12-31", freq="D", calendar="noleap"), + dims=("time",), + name="time", + ) + # no align_on for conversion to 360_day + with pytest.raises(ValueError, match="Argument `align_on` must be specified"): + convert_calendar(src_nl, "360_day") + + # Standard doesn't suuport year 0 + with pytest.raises( + ValueError, match="Source time coordinate contains dates with year 0" + ): + convert_calendar(src_nl, "standard") + + # no align_on for conversion from 360 day + src_360 = convert_calendar(src_nl, "360_day", align_on="year") + with pytest.raises(ValueError, match="Argument `align_on` must be specified"): + convert_calendar(src_360, "noleap") + + # Datetime objects + da = DataArray([0, 1, 2], dims=("x",), name="x") + with pytest.raises(ValueError, match="Coordinate x must contain datetime objects."): + convert_calendar(da, "standard", dim="x") + + +def test_convert_calendar_same_calendar(): + src = DataArray( + date_range("2000-01-01", periods=12, freq="6H", use_cftime=False), + dims=("time",), + name="time", + ) + out = convert_calendar(src, "proleptic_gregorian") + assert src is out + + +@pytest.mark.parametrize( + "source,target", + [ + ("standard", "noleap"), + ("noleap", "proleptic_gregorian"), + ("standard", "360_day"), + ("360_day", "proleptic_gregorian"), + ("noleap", "all_leap"), + ("360_day", "noleap"), + ], +) +def test_interp_calendar(source, target): + src = DataArray( + date_range("2004-01-01", "2004-07-30", freq="D", calendar=source), + dims=("time",), + name="time", + ) + tgt = DataArray( + date_range("2004-01-01", "2004-07-30", freq="D", calendar=target), + dims=("time",), + name="time", + ) + da_src = DataArray( + np.linspace(0, 1, src.size), dims=("time",), coords={"time": src} + ) + conv = interp_calendar(da_src, tgt) + + assert_identical(tgt.time, conv.time) + + np.testing.assert_almost_equal(conv.max(), 1, 2) + assert conv.min() == 0 + + +@requires_cftime +def test_interp_calendar_errors(): + src_nl = DataArray( + [1] * 100, + dims=("time",), + coords={ + "time": date_range("0000-01-01", periods=100, freq="MS", calendar="noleap") + }, + ) + tgt_360 = date_range("0001-01-01", "0001-12-30", freq="MS", calendar="standard") + + with pytest.raises( + ValueError, match="Source time coordinate contains dates with year 0" + ): + interp_calendar(src_nl, tgt_360) + + da1 = DataArray([0, 1, 2], dims=("x",), name="x") + da2 = da1 + 1 + + with pytest.raises( + ValueError, match="Both 'source.x' and 'target' must contain datetime objects." + ): + interp_calendar(da1, da2, dim="x") diff --git a/xarray/tests/test_cftime_offsets.py b/xarray/tests/test_cftime_offsets.py index 6d2d9907627..061c1420aba 100644 --- a/xarray/tests/test_cftime_offsets.py +++ b/xarray/tests/test_cftime_offsets.py @@ -22,11 +22,16 @@ YearEnd, _days_in_month, cftime_range, + date_range, + date_range_like, get_date_type, to_cftime_datetime, to_offset, ) -from xarray.tests import _CFTIME_CALENDARS +from xarray.coding.frequencies import infer_freq +from xarray.core.dataarray import DataArray + +from . import _CFTIME_CALENDARS, requires_cftime cftime = pytest.importorskip("cftime") @@ -1217,3 +1222,108 @@ def test_cftime_range_standard_calendar_refers_to_gregorian(): (result,) = cftime_range("2000", periods=1) assert isinstance(result, DatetimeGregorian) + + +@pytest.mark.parametrize( + "start,calendar,use_cftime,expected_type", + [ + ("1990-01-01", "standard", None, pd.DatetimeIndex), + ("1990-01-01", "proleptic_gregorian", True, CFTimeIndex), + ("1990-01-01", "noleap", None, CFTimeIndex), + ("1990-01-01", "gregorian", False, pd.DatetimeIndex), + ("1400-01-01", "standard", None, CFTimeIndex), + ("3400-01-01", "standard", None, CFTimeIndex), + ], +) +def test_date_range(start, calendar, use_cftime, expected_type): + dr = date_range( + start, periods=14, freq="D", calendar=calendar, use_cftime=use_cftime + ) + + assert isinstance(dr, expected_type) + + +def test_date_range_errors(): + with pytest.raises(ValueError, match="Date range is invalid"): + date_range( + "1400-01-01", periods=1, freq="D", calendar="standard", use_cftime=False + ) + + with pytest.raises(ValueError, match="Date range is invalid"): + date_range( + "2480-01-01", + periods=1, + freq="D", + calendar="proleptic_gregorian", + use_cftime=False, + ) + + with pytest.raises(ValueError, match="Invalid calendar "): + date_range( + "1900-01-01", periods=1, freq="D", calendar="noleap", use_cftime=False + ) + + +@requires_cftime +@pytest.mark.parametrize( + "start,freq,cal_src,cal_tgt,use_cftime,exp0,exp_pd", + [ + ("2020-02-01", "4M", "standard", "noleap", None, "2020-02-28", False), + ("2020-02-01", "M", "noleap", "gregorian", True, "2020-02-29", True), + ("2020-02-28", "3H", "all_leap", "gregorian", False, "2020-02-28", True), + ("2020-03-30", "M", "360_day", "gregorian", False, "2020-03-31", True), + ("2020-03-31", "M", "gregorian", "360_day", None, "2020-03-30", False), + ], +) +def test_date_range_like(start, freq, cal_src, cal_tgt, use_cftime, exp0, exp_pd): + source = date_range(start, periods=12, freq=freq, calendar=cal_src) + + out = date_range_like(source, cal_tgt, use_cftime=use_cftime) + + assert len(out) == 12 + assert infer_freq(out) == freq + + assert out[0].isoformat().startswith(exp0) + + if exp_pd: + assert isinstance(out, pd.DatetimeIndex) + else: + assert isinstance(out, CFTimeIndex) + assert out.calendar == cal_tgt + + +def test_date_range_like_same_calendar(): + src = date_range("2000-01-01", periods=12, freq="6H", use_cftime=False) + out = date_range_like(src, "standard", use_cftime=False) + assert src is out + + +def test_date_range_like_errors(): + src = date_range("1899-02-03", periods=20, freq="D", use_cftime=False) + src = src[np.arange(20) != 10] # Remove 1 day so the frequency is not inferrable. + + with pytest.raises( + ValueError, + match="`date_range_like` was unable to generate a range as the source frequency was not inferrable.", + ): + date_range_like(src, "gregorian") + + src = DataArray( + np.array( + [["1999-01-01", "1999-01-02"], ["1999-01-03", "1999-01-04"]], + dtype=np.datetime64, + ), + dims=("x", "y"), + ) + with pytest.raises( + ValueError, + match="'source' must be a 1D array of datetime objects for inferring its range.", + ): + date_range_like(src, "noleap") + + da = DataArray([1, 2, 3, 4], dims=("time",)) + with pytest.raises( + ValueError, + match="'source' must be a 1D array of datetime objects for inferring its range.", + ): + date_range_like(da, "noleap") diff --git a/xarray/tests/test_coding_times.py b/xarray/tests/test_coding_times.py index 930677f75f4..2e19ddb3a75 100644 --- a/xarray/tests/test_coding_times.py +++ b/xarray/tests/test_coding_times.py @@ -18,6 +18,7 @@ ) from xarray.coding.times import ( _encode_datetime_with_cftime, + _should_cftime_be_used, cftime_to_nptime, decode_cf_datetime, encode_cf_datetime, @@ -1107,3 +1108,21 @@ def test_decode_encode_roundtrip_with_non_lowercase_letters(calendar) -> None: # original form throughout the roundtripping process, uppercase letters and # all. assert_identical(variable, encoded) + + +@requires_cftime +def test_should_cftime_be_used_source_outside_range(): + src = cftime_range("1000-01-01", periods=100, freq="MS", calendar="noleap") + with pytest.raises( + ValueError, match="Source time range is not valid for numpy datetimes." + ): + _should_cftime_be_used(src, "standard", False) + + +@requires_cftime +def test_should_cftime_be_used_target_not_npable(): + src = cftime_range("2000-01-01", periods=100, freq="MS", calendar="noleap") + with pytest.raises( + ValueError, match="Calendar 'noleap' is only valid with cftime." + ): + _should_cftime_be_used(src, "noleap", False) From f75c3be583db377e1efe0fb90ece11e79bb4297e Mon Sep 17 00:00:00 2001 From: Zeb Nicholls Date: Fri, 31 Dec 2021 10:39:55 +1100 Subject: [PATCH 12/32] Numpy string coding (#5264) * Add failing test * Try fix * Lint * Require netCDF4 for test * Move fix to infer dtype * Update thanks to @shoyer * Whats new * Move test and add comment * Update whats-new.rst * Update whats-new.rst Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- doc/whats-new.rst | 3 +++ xarray/coding/strings.py | 2 ++ xarray/conventions.py | 8 ++++++-- xarray/tests/test_backends.py | 21 +++++++++++++++++++++ xarray/tests/test_coding_strings.py | 6 ++++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index cbc97250ef9..d5c2516910a 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -37,6 +37,9 @@ Deprecations Bug fixes ~~~~~~~~~ +- Subclasses of ``byte`` and ``str`` (e.g. ``np.str_`` and ``np.bytes_``) will now serialise to disk rather than raising a ``ValueError: unsupported dtype for netCDF4 variable: object`` as they did previously (:pull:`5264`). + By `Zeb Nicholls `_. + - Fix applying function with non-xarray arguments using :py:func:`xr.map_blocks`. By `Cindy Chiao `_. diff --git a/xarray/coding/strings.py b/xarray/coding/strings.py index c217cb0c865..aeffab0c2d7 100644 --- a/xarray/coding/strings.py +++ b/xarray/coding/strings.py @@ -17,6 +17,8 @@ def create_vlen_dtype(element_type): + if element_type not in (str, bytes): + raise TypeError("unsupported type for vlen_dtype: {!r}".format(element_type)) # based on h5py.special_dtype return np.dtype("O", metadata={"element_type": element_type}) diff --git a/xarray/conventions.py b/xarray/conventions.py index c3a05e42f82..ae915069947 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -157,8 +157,12 @@ def _infer_dtype(array, name=None): return np.dtype(float) element = array[(0,) * array.ndim] - if isinstance(element, (bytes, str)): - return strings.create_vlen_dtype(type(element)) + # We use the base types to avoid subclasses of bytes and str (which might + # not play nice with e.g. hdf5 datatypes), such as those from numpy + if isinstance(element, bytes): + return strings.create_vlen_dtype(bytes) + elif isinstance(element, str): + return strings.create_vlen_dtype(str) dtype = np.array(element).dtype if dtype.kind != "O": diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py index c4183f2cdc9..bffac52e979 100644 --- a/xarray/tests/test_backends.py +++ b/xarray/tests/test_backends.py @@ -5393,3 +5393,24 @@ def test_h5netcdf_entrypoint(tmp_path): assert entrypoint.guess_can_open("something-local.nc4") assert entrypoint.guess_can_open("something-local.cdf") assert not entrypoint.guess_can_open("not-found-and-no-extension") + + +@requires_netCDF4 +@pytest.mark.parametrize("str_type", (str, np.str_)) +def test_write_file_from_np_str(str_type, tmpdir) -> None: + # https://github.com/pydata/xarray/pull/5264 + scenarios = [str_type(v) for v in ["scenario_a", "scenario_b", "scenario_c"]] + years = range(2015, 2100 + 1) + tdf = pd.DataFrame( + data=np.random.random((len(scenarios), len(years))), + columns=years, + index=scenarios, + ) + tdf.index.name = "scenario" + tdf.columns.name = "year" + tdf = tdf.stack() + tdf.name = "tas" + + txr = tdf.to_xarray() + + txr.to_netcdf(tmpdir.join("test.nc")) diff --git a/xarray/tests/test_coding_strings.py b/xarray/tests/test_coding_strings.py index e35e31b74ad..ef0b03f9681 100644 --- a/xarray/tests/test_coding_strings.py +++ b/xarray/tests/test_coding_strings.py @@ -29,6 +29,12 @@ def test_vlen_dtype() -> None: assert strings.check_vlen_dtype(np.dtype(object)) is None +@pytest.mark.parametrize("numpy_str_type", (np.str_, np.bytes_)) +def test_numpy_subclass_handling(numpy_str_type) -> None: + with pytest.raises(TypeError, match="unsupported type for vlen_dtype"): + strings.create_vlen_dtype(numpy_str_type) + + def test_EncodedStringCoder_decode() -> None: coder = strings.EncodedStringCoder() From c5a2c687c0cd0e68e21ad606b8ba8868eb08eb81 Mon Sep 17 00:00:00 2001 From: Mathias Hauser Date: Mon, 3 Jan 2022 09:29:48 +0100 Subject: [PATCH 13/32] Revert "disable pytest-xdist (to check CI failure)" (#6127) * Revert "disable pytest-xdist (to check CI failure) (#6077)" This reverts commit 5e8de55321171f95ed9684c33aa47112bb2519ac. * Apply suggestions from code review * Apply suggestions from code review --- .github/workflows/ci-additional.yaml | 2 +- .github/workflows/ci.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-additional.yaml b/.github/workflows/ci-additional.yaml index 6e75587a14b..0b59e199b39 100644 --- a/.github/workflows/ci-additional.yaml +++ b/.github/workflows/ci-additional.yaml @@ -96,7 +96,7 @@ jobs: python -c "import xarray" - name: Run tests run: | - python -m pytest \ + python -m pytest -n 4 \ --cov=xarray \ --cov-report=xml \ $PYTEST_EXTRA_FLAGS diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ac9d30ab3b..82e21a4f46c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,7 +87,7 @@ jobs: run: | python -c "import xarray" - name: Run tests - run: python -m pytest + run: python -m pytest -n 4 --cov=xarray --cov-report=xml --junitxml=pytest.xml From d6ee8caa84b27d4635ec3384b1a06ef4ddf2d998 Mon Sep 17 00:00:00 2001 From: Michael Delgado Date: Mon, 3 Jan 2022 08:57:57 -0800 Subject: [PATCH 14/32] Deprecate bool(ds) (#6126) --- doc/whats-new.rst | 5 +++++ xarray/core/dataset.py | 8 ++++++++ xarray/tests/test_dataset.py | 4 +++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index d5c2516910a..4cd20d5e95f 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -35,6 +35,11 @@ Deprecations By `Tom Nicholas `_. +- Coercing a dataset to bool, e.g. ``bool(ds)``, is being deprecated and will raise an + error in a future version (not yet planned). For now, invoking ``Dataset.__bool__`` + issues a ``PendingDeprecationWarning`` (:issue:`6124`, :pull:`6126`). + By `Michael Delgado `_. + Bug fixes ~~~~~~~~~ - Subclasses of ``byte`` and ``str`` (e.g. ``np.str_`` and ``np.bytes_``) will now serialise to disk rather than raising a ``ValueError: unsupported dtype for netCDF4 variable: object`` as they did previously (:pull:`5264`). diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 360ffd42d54..b3761a7d2e4 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -1450,6 +1450,14 @@ def __len__(self) -> int: return len(self.data_vars) def __bool__(self) -> bool: + warnings.warn( + "coercing a Dataset to a bool will be deprecated. " + "Using bool(ds.data_vars) to check for at least one " + "data variable or using Dataset.to_array to test " + "whether array values are true is encouraged.", + PendingDeprecationWarning, + stacklevel=2, + ) return bool(self.data_vars) def __iter__(self) -> Iterator[Hashable]: diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py index c8770601c30..40b9b31c7fa 100644 --- a/xarray/tests/test_dataset.py +++ b/xarray/tests/test_dataset.py @@ -544,7 +544,9 @@ def test_properties(self): assert "aasldfjalskdfj" not in ds.variables assert "dim1" in repr(ds.variables) assert len(ds) == 3 - assert bool(ds) + + with pytest.warns(PendingDeprecationWarning): + assert bool(ds) assert list(ds.data_vars) == ["var1", "var2", "var3"] assert list(ds.data_vars.keys()) == ["var1", "var2", "var3"] From b88c65af4b02b7951865efb23f0d8fd62a15514e Mon Sep 17 00:00:00 2001 From: Tom Nicholas <35968931+TomNicholas@users.noreply.github.com> Date: Mon, 3 Jan 2022 11:58:50 -0500 Subject: [PATCH 15/32] Add labels to dataset diagram (#6076) Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- doc/_static/dataset-diagram.png | Bin 56103 -> 45348 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/doc/_static/dataset-diagram.png b/doc/_static/dataset-diagram.png index ebe61722832ab0a98f834dd6423f60a764c9377a..be9aa8d653cfbc21861050da4dad22cb190c24f2 100644 GIT binary patch literal 45348 zcma&ObyQT}8#j7}p*uvROF%+Ex*28Y?vU`2i7 z0281Lf2#K~b1Tc=NYC)1S5}U?l=3N$5e2Qh(2041dcKgVf%^++e`=DPp)fw=@(kgBPOiWrP!YK%4p`hC3wb#z;;rH-0I@XPA8IpLlpf&#NhEt!# z!`4M;6d|>M?a%kja{nKH@a@3ImM~rNk%TY-_{3y2h}W#1kVjEt+Q_8fa^uG_O#>_;R}Rf?g~h2cQGX0~k*>OsLwEEx>ph)$O6n>^gy zt=DO~e)VZj+bh~1>Vuq|n_MJkJJ>1d~%*T)&Moqm zfS<@{*Q%M3snx$|)_AVW!23QO}W-Kc)LQ;kXM~Zg0Eq#iTBmNh%l)nOPw~u+6kZ*R54T zJHu_4?(K;$cP|o-xrB8YcfUC`$F$tUmq!#)L+Xd1w~8+8oCdVR+v-O(Gv zy6qO>746dPjAVyjGEUB1+TS71uLZYTEg%`;^6gDXg7oYen-B!gQoTvD6lOCp7kw57 zT#=j+EW<-_mM@PTOlpjVAhHVWOQ)nhr+C{>u9w#tx_?*@?}c=q9t2=w%Dyv7iT_lG z*-m?gYlO!$wzG&E=@@_e;3dJ6#YDs+RI=gQ>Q!tz?zTSxie4;#K08XR)l^x4`6jH5 zGlQ1U_xxk81Zx8Mj>J2$Y(}4;>mL(*I3pMIE~+;(E1%!Rxcq#d$8EI6*UKl5$O&b_ zeP#fb`W&NF$+SKe8y5N5~3q@dHDm`cnJYrss zkdJ=%8^*iW=oiFky|B?gyW-TM1bO0bz=9y}4!((*2)MGzCG@H~#MtB8zQO1&4ZjIN z%G+K$xYXaq0LEhe;tPgB^YrdvUF0S;N*D4;tyjT}71X5}q|~EI+kQ+K0*fg3TNBzY z%le^ZA^v3U=jrWcGrJ4OAzQ5Fmj|M75$bOFo~M{gi}66-518S$AH>>j%>g;IYS#Uu z{0l7^H>|NWTFkuyhe6TwZ{sB9V$50CEYRF3DmVSwk%fiXa>89xj;@lH)aPokmSJ{{ zd3A%4lv^hj`OZB=<^*o~SCl#T@U%S?p3oloGnWKDkVUh-7KR+!;IHt(?RIF>xtyon zpD&&4se^iDPPS3W%%nNj_P!%4GmRoN^QKNmov_KmPkuxi!tkm8_d;5`SS+tTziLF-K4J07pfnDpc9$hDVpys&88s?9DR zM6#)#wq0vZ%{hjzeq8Sw&0nOP0Sh_IZ$0vO9Azu zme%fTJZs{*!Zy@J$W-jl+Qo~kr!3X8s^Ow!#?80PGHn=`zKja(8GIGOd9RTNQJ-cGG01wcPI>k{ znanRk*fIDCNc&fG=NX`BuYgLm?foan@|U^!Z8Zee$s29R011pIhM}LNcPeAo2w8#{ zU#A&f$fFp^dM_4`7G?O}jeDzg!&3rp7{O7%Z{KiA~=VUlx6fFJDlDjQ_ zsK&q0wFz+|1-ieRWS&i@&1>f6rtbU4ZS%X$`#k?le~S3NK>rM==O**alPkV#-i{Sx zt`yr9Wy#6;nKWg?yzOPCU^jW~;{#s1^UR}(Z^{`7^Eq`7qq zm(Kdq%QcskewX|h19G)t{iKWgv6-{E4S4%u<+5!Yi{%`omY+rE$`wo<(HE5YDc)6n zLG)j7AZ2Sqk2S&We0xxjJSbqU^k9i=VneDqkXkb=MkvaJ8QPR-KQYUts$oZd;~Qtz@JvT? zYm*u_obHV$uDy1Syx&zaAE%IsNnb0%+_H6Yh1sqAsiOS%^^A)HD{+1+GO_uKuRNjR$ip5F+EEYI+TU@nQu}3 zxbN`9WwzT&zzz+5^Q)gX4vxF0bMrYFgE2dR=!O6|gcj?}|77eUOVg-al&1?u3Zu{6!or7c3x+_8?Q@=PeK^(>_{DRPT6tOL4}Q&)O(yEq+7t@}{2Fx$Ps|<&~*nH{PD=%uSnlEShJs zU*L*Vb!LcFV?(lba+6AIQPI|K=Ism73Wb9zr0YhgS@(IzP{L#Ey5-8v(Y z9s1@G?Ss$rJ201!zjgT*OfpTskX!o}e8ZP7_s6{-h7hC6WI4!u6mHL3L_{b{i|WGl zQe`oC+jhPel^N6K1UVrV+SlLAQK%XGmR4BtoBbTSD~jBWx)IEtSODarc++Fl(Ekjv zSSV_sUe-a3UL()nis98e<33)aVfDI z&vWrpm0MQ$w~Ke6?mS9r=(7uKAn-z>nf9bC893NCU2EO(ZXLx$^_KT6S% z_NVb>QaL6mfiBZj#+j|JF=<0t?p8RD<&*!cK1HKCGf4^EY*kWx$v{F zO1O~5=f>D=>M^^*dS+Al3l^G9SUJu1pSdl^=Ug!0&meLa3;8aUpu}+8kmP9f5ns1_ z_7@&_{vQwA2RkuJ#I8kkMV6SI`xEiEyWJy`tmFKzMz|R+!)JGJ*ppE^Rsqts-;C-C? zE^UFdp@-{w#Lurc0J>5-l%oPhSr%I%YQ4}nyZe&0{VWtdS#`O1zuRy-?U^IiTW(naisV$84B<4z=68}~wMf^-! zth~PA!AQ!He~9D9;;e?l8do=neD?rRPbOR|>J+~%AN<9iRYmc{sS-!q-IK^B6H zbO&zA^Ov=3YNul>+5aEv_W%B#JU z3I?#D%`Is>Pwg0@xdNah&`Ai1*=p4T0kF5<1A-ynV;EP~$ZuS+pE)w(W9WRp%6YTz z^WwG~(&%Z2UV{nlt^Z>{vIIceFkRbwQv-OS2$g@Wd6O)W_c5MfrU|}WhJT@zHsP9j zbKtF!Ev4-1TkCr4MwYHG>|0}3s)Hm)%E=C=6zyVuIz>EWSR+~~UPVcV^`nSOHU zq(i$?Nk@q`>0TJ@YRl>q^<)@QkN@3|XErtzA3k&y;C%Dnm5IQBuFJwg<%ML!NUZYm zB2V9E6loN_%gl7eCT@=7HT%0-(zRpR8%H1WKs*5fgq0W>lqF|L`UwOcIbN~Z2#bh( z$jInVdt$S*K`O>7{C!tJ7Uu@>D5HVqFP0QRb0k7rF6xV; zSq13^$rVnTpzDm#s?;TqjD1aSheoDYxZR=s{e>5)Lb%CENlT87mcw%mXUYgtn7KJa zmMQP@^77vPhs(-=zP|jhXkxVt5h6amhQ8OrE))EIzv&cIBicsSA3lB@o3YanohKKq z^26EP-F|(f@S)5eFliCi((7fa!SSoIO~Xu|QYJinik1duN@D7cl@CGB{h@gI_>W9* zvy`kNB^A}mO8G;lj;yoG{%|b~4PyGH6T69Wz($uDL{TU3Kb>!VN7rP}Cx8%$m2 zzJFk5EqVg(NQn_~^eakkgT0@FhtvXa;;Z9;3;5Nx&f&e>l9D%LV|tH8H4pe+3NPEMSCC90af zT*>yK>MF{rGz3URfvn4`Lmj}x-2C@v^$cWuydg%ev9a-hK4$LycdyURUa|ou&!6{$ z>nsGu{(+Eh+eG5UMgbuVhlu7OQ7(9jg{rCET|}ZvSyNfWICq<}P^yo4y55!Bw3|Yc z`V5OTg=S@qIOpdko@^2}s=SeI@~2i1YFy;qD?}K?howl} zX2)mf&ttD;ZazMTm7ds#4@2jPTPu1{VI#xi8-U*9$myQTdo?z7uLQcs zNYOnMpVeKqf4cnG%XNzrHi#ia5NXIIyU!otbQiR{V{$BMcSII=-GgKR->woq9l z)~l#rhCBIxCEw!>e?2rfWoj05kEZ?7Epy{j$k%cEB{e69la)ORotxV)%`O*}gm#WV zSuX9i&;48(tlq zr6o&+SQ<8A_UNvsfq@ctH$niQe_|aeg!s&7KCnZ{jk_}%on~=`vAASeQpKsmzsE=M z`7EF+@hoI+{!FDkJ2X$of5&tnG`o_ay#e4#guG}l{rmT?macABtW3Y=qP+Hgng zjDG#85TI_1`AO;)cjjF)!U3^(a&XAXDlr3S$r&bpxsu1vvs|idzq)(s;0?}o8q{Eh zC^r@=G7^&w2t3=fvLDK>cnB@Uhl#q)z_qmASI;ztLvgr_BPJofsNAR7@xSa@SF-$n z4*q2N?qOlg5Jc$H8>eHV`w)`LuDjg)hvIyGi_!ArxrJ)5Cg? z8t1w`q=@bb?PX-!*BC0O5kA32&@}e>3d0;p_e3p2$6}}-N}WG!CI9Nb+>Iw7DEQ{x zyF_eMLIQaqHL5F`#Amajo~r~ho+_emo1zC4x7Lb-(_x(=$j3*pKjpE_-=7~;03i

`G|3kUG#M;dkXw;q{M=BPY=`R?KrGlBbfBd)up*7D8FygpkLTXMREv>H z3t2mWhJu1sSo7G33#k1)^Rp`_?5Bb#ux;PU(!OT3wEeY=1Qs^mKQtsM{l7yf{NjsC z1l&Ld-et;|;UV0ot6v+}*ei=N8}r<+uC7*6QPIKAZ6#;J_H?I6AQZv?i|#2W;$(Nx z;-cHV&cyheYkDwI@w~>x@{h;VD6}Hu4<0AcLZ$F_4tAwX$>0bbAh`uS0ukcn)sWE5 zNlcV`@uF^ou-LVty6cbSzrJLZyOVGOZ<4fh79It=62wuiCK*TeGS?381|I{Zh!AbW zTFwf+)UUy;gcfU>_zHE+DxSwP%k{lrlPi|p^RRS}hCtE6HrN3{r-5_>_BB;5lf2f} z)?%JZh_UvMA3si2+2DXJ#ip`54Ja)un{M*wv+0Rp=Ht`kGSJtj1Y7F|R>g$GL{`ep z?l%;A1_tC56mouk-}K7CL(A0QI6gk!nyFLenpkKF!XqRsXl$H0G~%TyEHkJI+cv4f zTxz1agq@wP+mLu+tw{)>x?&v|wcZOzteI$JsnW!o*bhQ!cIrp(QmmKZtw z&dmG%J&o4=`_sT|2HDWe(IQO>N=i0q&Byl%{F<_&qOkw#LsM;GvNtz3V`F1Vy))pt znVFe0#k?XYIHYFVe?}#qp1N78PD5BEeI2~?FqjQ{$gjHEX_>>%zI zP#JS=%+#0&J!4J|2*5nvdF%|E?IjN;K(NE({yB6xEU{I&Mf|YNYi`cGE3u)`tE*sB z-qewRz?E20l^0krUqA;P*@voWg2OoJd;tL#?2sXt(NGtM! z%O@wyypceLgCqJi0Tp-$D@sePJ0h?~2I@;=RfdS0_1;nkSOx}MDXo~Y0SC5rhes37J@iMg zaz1`kv9``{CcAdn^~r6F*lB%w)jSl*jRj-GyzfuYeHn)?!-nNxg-(w5PWlF=dO~4D zW)zJhJRc(vVfmym{b~Mng(IKU0c~rzg^tcMaAJ<|zBC5L-O}_R=;KL?$^rs&Wr~D( zjhPIO>YyL^5iBgo;4`cDS6h^v59(=9lWhLaxHvhjKwzjMqK4Y;)DvR1b8zrE*)q11 z$px!$1lTYyD|gN=t+vt`k32#-Fn%q0$=4;}yC(X|qjPd>M-z`76VXa>KhnR49#vyD zb*!Rn5pf&BCo4O;_Thu?L8FNuwXiKZV|*uG%-wlCVzPeORTC(s9qt!l{`c>nwuy;K zaBy(b0p!JQMCykolH#`%LEhfgKEA!4N(TI6C|ri`K^|UWhEP?ut~;5FsNa&BcTnR+?9o6UeZ!$JF z51)*++&)9TMAYF<@(T1q@PAXP@`|vSIJa#y1ZEQvLs*>WqG*U8xd8+||9iClQ2JU( zhMZ#_LQmD@t1&omxt8FvPoQO7GB)gL2P2Cqrb;HYYkdUX!>Lx-}RBV=%!DanyT`WU(9?TNU+ zv9fS5SnyrWL!=w=j?Wt1rp8L{j>P^=qGLcn009D9+1v~4V18Ru9hu@jNn?nVsY#|DF2g`&X43u66lZP0-1TmlY7P(tZ-a`8yqsZ`z!^+tR`GVOtroaXa?l*;(mzRn_ZYkh9lr3$3D#|5Db7<+t zGi5BqjH^frp9Qb9<{xqZ-4Psx8NKiByPT{Cz7Uy$N`_iU_h3{3+w4>JBD7c&EJs?5J{8XyVnhl-U4fSnc%dqU-|Lz z@gF^Uv?rD_Xuoo{zY_WSbrm>=PPV33wCRevI`YQLO89u$i~T*KWcMTWC=y2h?48xE zDdV8TS30rzJEmVJ{mUN0I}P~w;RCbq*SBx+n_F7eXX?t1q#pC}^)4>vXkVU_9n>YIGNYuV zyjxJnrCYk+QqUpf5w}T4NmZft~N^aiMSt~9lPUW+}K5kzj?9}ApJ+Cgs}LBwbkXxwu)=o z!NCDYMpOJ;JVDsMx;oO}<6!4b)Dm=RKlj~>p*MVj_w46SrE|5G6Ih^3*-BwhGaW49AM|Wk$G8s4>2)CfAweS$rg4bO(XtTw)3;+jZ4w$S$&yn z*}53HTq&&cO-uR!xNZ;wJ;g(0i~A^)5GB6dS&oSzyyIpCc%0(m>7a(P&U;1~2yPRm zSxYE- zPX9zT#f(~DjA6{&-ivgPYU#4;vpVZ~xrA?bJ&^X)^K^%y_vyfSSQ`JJ z`djLu{`~`WrKW0s&e!o_TF z7tVo_XMvgw8+C=LR)2Rvi8Y>?>BwE;ApE-`_B6~Nr9$c?mb!SVvey2#_VxHzjbywJ zo$PWwF(IRnOM>gVR?K&(zK%|xWsQ7eHv2lYidk;NnS_)E381h(jDG&`XdQYda)9F~ zT{`Gn0($|dMA!i)1_nLl#x+U49XaAYQQ(nqK~YsUy|AUjn5EYWYZLFqdgbmG~QQRae0*g5mXhaw~$Tw5}I0@apTaQ;2z$I3^nx_g02G+ z`8a9~!9eLH;^te?^i~!F1*;uc4gP!9;gH;@FEVC3AGN}|$*)eqLRIP1)l{D4cUx#S zenX_|#wnXrGHX&CKUxmMIc8Q|Kgg)aC6m_LWrIn z-q#UP`xh%5Z1u~2xcy0_X&_)mM~={(+4h!X7M5IkmBnApq1Mt&jp7JjJkhcIZ|!9f zU2_wHk$C^3t>S`tjYOlT(HiN`Mg)gyX@T(!Nj<P2*Ve71=bu7GX{IA?fQ{#-Z(`AAA6UUv=(ADl1w5hUCaNqu{C|aZrsI$ zQTY?KXENjE5_2@ysq?H@^sv38*u%`n9^-f{VS*1kW;;8-+uye@#8XzT$wbr^8Kt~f z?mB(&K(;@Dfq_AF=yf1DIXQUm?mCbi>~v>dRa=`(Sy_3+Y;Om9kxMXFz`MZ|Jaa+I zc7z`^SEDHLsD6P36b5q5TM9y8ZpX*>J{O?EE_^A03qDJYXJbYSN>_P$RKZS8oU`BD zY!;dW`aq2GxFbg<O;UM`sdobMIx53m~(ept|Qr)!abD4sS1z>FZg0lMR>? ziDsy;KY5~~x_4}X?VEjfUfv;-q%^o!lt!q$urTbQ^sZ1m3Lj9SvbSdkxi`ptjhroG zV`K05-u_CjuAbgQ%Cg4Bk08$atdZ3RQosUheSLjxBcq?73_jK5U#VAa(;Me?@K^cn z62;U$9b=i<_5&#@$lP?Qz!`>%kKgHB7s$~6FW>eD{()fyOR*uJbxx>BU(_$F8f|@3 zwIh1}VsykU2fx4Vn)#JwyAko$?5@|>o*sp7?(=;G%83PQ>!1SqtH0l7x%XxCc^ovh{H(9OraiC|QNtUyIsfrpELbgjhz}HJm6h=b?(=|K1Rl)T#DqZD zO>l`AQ_7(_JH`I~KIn+>Qa%6Xnx`1gKnM~gt`txN>WU&Fq!o!_kPcM(QEgIDZr<|o ziA|5vpsw`9M`4#V0Xuwp+Z5-Fj5GBf**`*7;17@-Qcbm0^LI)g4{W$UDvz$|Rx zSDqrlmdNqxQ^?}&&1v!!{~Z`TKU`CtocOo4Hi|;Qo;{23C2|BEgVxs8KG(Fs)18We zC?`;-gUibY(BYVbz>3t;J3(_!iZOApr|)#(lk29zm7LtVm~&DyiK8=DOK)7^>eQ3? z6{giIS+%rPwjVL0jZ4yelAN5Je?ag#=Qr!7veijLORKD+lJL!HrX@(!zQ#D6#1X`L zwPY>e9vyFtclY$D4C$7QtC*P3Bywn`d@KqqPfEJy-tg~!3ELT{!MB-QC{}a#BlmW@ zNdmi7#t{ZW6MJn5W^;68uT%g27_3JZwFl*ezw9N6+D{NsW3=l)dQNd33nwQK=ki5o z44Ml<zd@)7UZoqOQ)Y{X>~WSWj(&q)G`7v`9;a{VN^7lY!>I63c(2ow%OyJ~)` z(QbsH7L*1t^;Q>f%#=cuF7Q7E8Q{)*Q%PxQ0=CvXj-EadI@Kand3T{D8RBSbTOfqk z_a2#xf7OjvjW-mnR_o^^&yvWQwKa8C6(!f5r9_2)iD)l3_kZ}6H%jnN0pfMQAGJ9#$1*B`8_TRT(cbs94k2@X$ zqaw~0`w-A}LD?D_x_FRKX=rE1wmDJ33To)neb41^R;aM7*M{3Gs&%OGf=r%+E_}uCq?AN$87$EFF)Z?Sn_R1@O z7K0>!k&&?{mbSB2s`Z>3u_2SoOm4PU^l{_t?~=w&bLdu~ zdBiQ=UN$~ZC;6vhJ9Qq(VB(h6JZfWIAehT{pU(cbe-l-(;2zmtv0szq*Zc1UQ7w(O z-j{fvl0T@ya8dG?W`-P?$S*h=4bt_jqV0!|N|!KC3&Gv@oA> znf<6_4vRlrD0`d^Smf5sJ}{SeE&C$7Bfq(Zn%=(o4HNx+ZF&Ngg&%`dsG?SjnBiiA;W4r zS8(2eb8Y)Ka$#~Z6@)UXpt6KQB600#*^348HIOCn2UbscPzRnx9huGz78mbW+8cY@IRXp!pR zV?>su|FWmp1L?rpvBfoCiRtfNta*8PgmhwBTq*PJ9IULbp1#5+BO_y`=LW+H+Mg82 z$jO(z$`zVoi$-pHW>~bLjSnTX`*6l~ybp4gv*tu!E*G9auAgAQT6FLKf;?((JN$Ek z?*9mJ+lPR7x-bOh)j{5m$FQ-u;w{W<2Vn&I5zdx0OU-mpry_jdJ|qmoI$Xk1_$xH+ z_0LD4k%&q-6EVc@5HXL7yM;|CAKc0ZY+8}{WmFMITFt1nk$AR=81K{Dbl17*bl`LfvBHhpVR&40X zW5%Ioc{owQfKyglnxB^!4xU;ysG^mGC#R;ac#47e2M44piEm$kZtvCgH69U>9f&S; zs`~o+&~puf;Ktj72=Tu|2c^(P|6L7FPyWrx%2P2AxfXyf8psxOw6&kg%fIfeauuNk zu{q%be#A;|ywBB{*HDjby>8lvry<%OrU-7Tdch9$fR z?-vMK%TI*jd*>d3(T!fw6y$t7c5Xja--`}WNy|&^?@owdBFDseo4x}K2At5vdDoM^ z_j7M+e(zY$rkm_+(f9o&6$jkBCA_|wV_KBo5J(a5C?X>AeDvcGirg%6FX{QQAO*th zhcI^Q^ahh4^~V^NE44YFX0F>Xcc48iO9QmSdo@tVOMhq^7BSdBNxxHk$AjcCE$PZu zc|bSFwnU(i=t@VM`FB63nh+1+u^WqIqhX>Ln7c)8xOFaJ??T4Bn&Wo^@C}J$h&CC! ztZdZvQISgHQcG|Mn1-32Q|rjN>0ZyIBhT;McfM*F5m9^b;zf6FuWD-6Ul7O7&GEEg zR(d>M-`%Y&ComtEG6NG0f)2kQN=VGCfCOc5bab@gc;Y#=gm1B4`RC8^lGO zp@0zxJOA)%6VNcH^OSo;k<_&Bd`G8l02goDe)X@-hfnrzW9@n#SpTsKR%gG!3|wG^ zcuv@_(f51NWj`$@*#qaml^b$vjJE`07E)Ru{9F{b%C@z0+e^?oG?GQ&U1KL%|EVhJ zaebp2()Ca1cBpjIC>i?A�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 N%jpx&j{o+D{|lrIjs5@t From be4b9809b47e1816dbf6a37a263aeb77df95e6b7 Mon Sep 17 00:00:00 2001 From: Illviljan <14371165+Illviljan@users.noreply.github.com> Date: Mon, 3 Jan 2022 18:38:47 +0100 Subject: [PATCH 16/32] Limit and format number of displayed dimensions in repr (#5662) * Truncate dims * better name and no typing * Use limited formatting on dataarrays * limit unindexed dims, code cleanup * typing * typing * typing * typing * typing * handle hashables * Add test for element formatter * Update test_formatting.py * remove the trailing whitespace * Remove trailing whitespaces * Update whats-new.rst * Update whats-new.rst * Move to breaking changes instead * Add typing to tests. * With OPTIONS typed we can add more typing * Fix errors in tests * Update whats-new.rst --- doc/whats-new.rst | 3 + xarray/core/formatting.py | 105 ++++++++++++++++++++++++++++---- xarray/tests/test_formatting.py | 50 ++++++++++++--- 3 files changed, 139 insertions(+), 19 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 4cd20d5e95f..7b989495c66 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -27,6 +27,9 @@ New Features Breaking changes ~~~~~~~~~~~~~~~~ +- Improve repr readability when there are a large number of dimensions in datasets or dataarrays by + wrapping the text once the maximum display width has been exceeded. (:issue: `5546`, :pull:`5662`) + By `Jimmy Westling `_. Deprecations diff --git a/xarray/core/formatting.py b/xarray/core/formatting.py index 3f65cce4f68..05a8d163a41 100644 --- a/xarray/core/formatting.py +++ b/xarray/core/formatting.py @@ -4,7 +4,7 @@ import functools from datetime import datetime, timedelta from itertools import chain, zip_longest -from typing import Hashable +from typing import Collection, Hashable, Optional import numpy as np import pandas as pd @@ -97,6 +97,16 @@ def last_item(array): return np.ravel(np.asarray(array[indexer])).tolist() +def calc_max_rows_first(max_rows: int) -> int: + """Calculate the first rows to maintain the max number of rows.""" + return max_rows // 2 + max_rows % 2 + + +def calc_max_rows_last(max_rows: int) -> int: + """Calculate the last rows to maintain the max number of rows.""" + return max_rows // 2 + + def format_timestamp(t): """Cast given object to a Timestamp and return a nicely formatted string""" # Timestamp is only valid for 1678 to 2262 @@ -384,11 +394,11 @@ def _mapping_repr( summary = [f"{summary[0]} ({len_mapping})"] elif max_rows is not None and len_mapping > max_rows: summary = [f"{summary[0]} ({max_rows}/{len_mapping})"] - first_rows = max_rows // 2 + max_rows % 2 + first_rows = calc_max_rows_first(max_rows) keys = list(mapping.keys()) summary += [summarizer(k, mapping[k], col_width) for k in keys[:first_rows]] if max_rows > 1: - last_rows = max_rows // 2 + last_rows = calc_max_rows_last(max_rows) summary += [pretty_print(" ...", col_width) + " ..."] summary += [ summarizer(k, mapping[k], col_width) for k in keys[-last_rows:] @@ -441,11 +451,74 @@ def dim_summary(obj): return ", ".join(elements) -def unindexed_dims_repr(dims, coords): +def _element_formatter( + elements: Collection[Hashable], + col_width: int, + max_rows: Optional[int] = None, + delimiter: str = ", ", +) -> str: + """ + Formats elements for better readability. + + Once it becomes wider than the display width it will create a newline and + continue indented to col_width. + Once there are more rows than the maximum displayed rows it will start + removing rows. + + Parameters + ---------- + elements : Collection of hashable + Elements to join together. + col_width : int + The width to indent to if a newline has been made. + max_rows : int, optional + The maximum number of allowed rows. The default is None. + delimiter : str, optional + Delimiter to use between each element. The default is ", ". + """ + elements_len = len(elements) + out = [""] + length_row = 0 + for i, v in enumerate(elements): + delim = delimiter if i < elements_len - 1 else "" + v_delim = f"{v}{delim}" + length_element = len(v_delim) + length_row += length_element + + # Create a new row if the next elements makes the print wider than + # the maximum display width: + if col_width + length_row > OPTIONS["display_width"]: + out[-1] = out[-1].rstrip() # Remove trailing whitespace. + out.append("\n" + pretty_print("", col_width) + v_delim) + length_row = length_element + else: + out[-1] += v_delim + + # If there are too many rows of dimensions trim some away: + if max_rows and (len(out) > max_rows): + first_rows = calc_max_rows_first(max_rows) + last_rows = calc_max_rows_last(max_rows) + out = ( + out[:first_rows] + + ["\n" + pretty_print("", col_width) + "..."] + + (out[-last_rows:] if max_rows > 1 else []) + ) + return "".join(out) + + +def dim_summary_limited(obj, col_width: int, max_rows: Optional[int] = None) -> str: + elements = [f"{k}: {v}" for k, v in obj.sizes.items()] + return _element_formatter(elements, col_width, max_rows) + + +def unindexed_dims_repr(dims, coords, max_rows: Optional[int] = None): unindexed_dims = [d for d in dims if d not in coords] if unindexed_dims: - dims_str = ", ".join(f"{d}" for d in unindexed_dims) - return "Dimensions without coordinates: " + dims_str + dims_start = "Dimensions without coordinates: " + dims_str = _element_formatter( + unindexed_dims, col_width=len(dims_start), max_rows=max_rows + ) + return dims_start + dims_str else: return None @@ -505,6 +578,8 @@ def short_data_repr(array): def array_repr(arr): from .variable import Variable + max_rows = OPTIONS["display_max_rows"] + # used for DataArray, Variable and IndexVariable if hasattr(arr, "name") and arr.name is not None: name_str = f"{arr.name!r} " @@ -520,16 +595,23 @@ def array_repr(arr): else: data_repr = inline_variable_array_repr(arr.variable, OPTIONS["display_width"]) + start = f"".format(type(arr).__name__, name_str, dim_summary(arr)), + f"{start}({dims})>", data_repr, ] if hasattr(arr, "coords"): if arr.coords: - summary.append(repr(arr.coords)) + col_width = _calculate_col_width(_get_col_items(arr.coords)) + summary.append( + coords_repr(arr.coords, col_width=col_width, max_rows=max_rows) + ) - unindexed_dims_str = unindexed_dims_repr(arr.dims, arr.coords) + unindexed_dims_str = unindexed_dims_repr( + arr.dims, arr.coords, max_rows=max_rows + ) if unindexed_dims_str: summary.append(unindexed_dims_str) @@ -546,12 +628,13 @@ def dataset_repr(ds): max_rows = OPTIONS["display_max_rows"] dims_start = pretty_print("Dimensions:", col_width) - summary.append("{}({})".format(dims_start, dim_summary(ds))) + dims_values = dim_summary_limited(ds, col_width=col_width + 1, max_rows=max_rows) + summary.append(f"{dims_start}({dims_values})") if ds.coords: summary.append(coords_repr(ds.coords, col_width=col_width, max_rows=max_rows)) - unindexed_dims_str = unindexed_dims_repr(ds.dims, ds.coords) + unindexed_dims_str = unindexed_dims_repr(ds.dims, ds.coords, max_rows=max_rows) if unindexed_dims_str: summary.append(unindexed_dims_str) diff --git a/xarray/tests/test_formatting.py b/xarray/tests/test_formatting.py index aa4c0c49f81..529382279de 100644 --- a/xarray/tests/test_formatting.py +++ b/xarray/tests/test_formatting.py @@ -552,18 +552,52 @@ def test__mapping_repr(display_max_rows, n_vars, n_attr) -> None: assert len_summary == n_vars with xr.set_options( + display_max_rows=display_max_rows, display_expand_coords=False, display_expand_data_vars=False, display_expand_attrs=False, ): actual = formatting.dataset_repr(ds) - coord_s = ", ".join([f"{c}: {len(v)}" for c, v in coords.items()]) - expected = dedent( - f"""\ - - Dimensions: ({coord_s}) - Coordinates: ({n_vars}) - Data variables: ({n_vars}) - Attributes: ({n_attr})""" + col_width = formatting._calculate_col_width( + formatting._get_col_items(ds.variables) + ) + dims_start = formatting.pretty_print("Dimensions:", col_width) + dims_values = formatting.dim_summary_limited( + ds, col_width=col_width + 1, max_rows=display_max_rows ) + expected = f"""\ + +{dims_start}({dims_values}) +Coordinates: ({n_vars}) +Data variables: ({n_vars}) +Attributes: ({n_attr})""" + expected = dedent(expected) assert actual == expected + + +def test__element_formatter(n_elements: int = 100) -> None: + expected = """\ + Dimensions without coordinates: dim_0: 3, dim_1: 3, dim_2: 3, dim_3: 3, + dim_4: 3, dim_5: 3, dim_6: 3, dim_7: 3, + dim_8: 3, dim_9: 3, dim_10: 3, dim_11: 3, + dim_12: 3, dim_13: 3, dim_14: 3, dim_15: 3, + dim_16: 3, dim_17: 3, dim_18: 3, dim_19: 3, + dim_20: 3, dim_21: 3, dim_22: 3, dim_23: 3, + ... + dim_76: 3, dim_77: 3, dim_78: 3, dim_79: 3, + dim_80: 3, dim_81: 3, dim_82: 3, dim_83: 3, + dim_84: 3, dim_85: 3, dim_86: 3, dim_87: 3, + dim_88: 3, dim_89: 3, dim_90: 3, dim_91: 3, + dim_92: 3, dim_93: 3, dim_94: 3, dim_95: 3, + dim_96: 3, dim_97: 3, dim_98: 3, dim_99: 3""" + expected = dedent(expected) + + intro = "Dimensions without coordinates: " + elements = [ + f"{k}: {v}" for k, v in {f"dim_{k}": 3 for k in np.arange(n_elements)}.items() + ] + values = xr.core.formatting._element_formatter( + elements, col_width=len(intro), max_rows=12 + ) + actual = intro + values + assert expected == actual From 83095992a39801ccaf6e2b779fc8ab1caae914f3 Mon Sep 17 00:00:00 2001 From: joseph nowak Date: Mon, 3 Jan 2022 13:55:59 -0400 Subject: [PATCH 17/32] New algorithm for forward filling (#6118) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Deepak Cherian --- doc/whats-new.rst | 5 +++ xarray/core/dask_array_ops.py | 50 ++++++++++++++++++----------- xarray/tests/test_duck_array_ops.py | 24 +++++++------- xarray/tests/test_missing.py | 4 ++- 4 files changed, 52 insertions(+), 31 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 7b989495c66..c63470535b1 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -24,6 +24,8 @@ New Features - New top-level function :py:func:`cross`. (:issue:`3279`, :pull:`5365`). By `Jimmy Westling `_. +- Enable the limit option for dask array in the following methods :py:meth:`DataArray.ffill`, :py:meth:`DataArray.bfill`, :py:meth:`Dataset.ffill` and :py:meth:`Dataset.bfill` (:issue:`6112`) + By `Joseph Nowak `_. Breaking changes ~~~~~~~~~~~~~~~~ @@ -45,6 +47,9 @@ Deprecations Bug fixes ~~~~~~~~~ +- Properly support :py:meth:`DataArray.ffill`, :py:meth:`DataArray.bfill`, :py:meth:`Dataset.ffill` and :py:meth:`Dataset.bfill` along chunked dimensions (:issue:`6112`). + By `Joseph Nowak `_. + - Subclasses of ``byte`` and ``str`` (e.g. ``np.str_`` and ``np.bytes_``) will now serialise to disk rather than raising a ``ValueError: unsupported dtype for netCDF4 variable: object`` as they did previously (:pull:`5264`). By `Zeb Nicholls `_. diff --git a/xarray/core/dask_array_ops.py b/xarray/core/dask_array_ops.py index 5eeb22767c8..fa497dbca20 100644 --- a/xarray/core/dask_array_ops.py +++ b/xarray/core/dask_array_ops.py @@ -57,24 +57,36 @@ def push(array, n, axis): """ Dask-aware bottleneck.push """ - from bottleneck import push + import bottleneck + import dask.array as da + import numpy as np - if len(array.chunks[axis]) > 1 and n is not None and n < array.shape[axis]: - raise NotImplementedError( - "Cannot fill along a chunked axis when limit is not None." - "Either rechunk to a single chunk along this axis or call .compute() or .load() first." - ) - if all(c == 1 for c in array.chunks[axis]): - array = array.rechunk({axis: 2}) - pushed = array.map_blocks(push, axis=axis, n=n, dtype=array.dtype, meta=array._meta) - if len(array.chunks[axis]) > 1: - pushed = pushed.map_overlap( - push, - axis=axis, - n=n, - depth={axis: (1, 0)}, - boundary="none", - dtype=array.dtype, - meta=array._meta, + def _fill_with_last_one(a, b): + # cumreduction apply the push func over all the blocks first so, the only missing part is filling + # the missing values using the last data of the previous chunk + return np.where(~np.isnan(b), b, a) + + if n is not None and 0 < n < array.shape[axis] - 1: + arange = da.broadcast_to( + da.arange( + array.shape[axis], chunks=array.chunks[axis], dtype=array.dtype + ).reshape( + tuple(size if i == axis else 1 for i, size in enumerate(array.shape)) + ), + array.shape, + array.chunks, ) - return pushed + valid_arange = da.where(da.notnull(array), arange, np.nan) + valid_limits = (arange - push(valid_arange, None, axis)) <= n + # omit the forward fill that violate the limit + return da.where(valid_limits, push(array, None, axis), np.nan) + + # The method parameter makes that the tests for python 3.7 fails. + return da.reductions.cumreduction( + func=bottleneck.push, + binop=_fill_with_last_one, + ident=np.nan, + x=array, + axis=axis, + dtype=array.dtype, + ) diff --git a/xarray/tests/test_duck_array_ops.py b/xarray/tests/test_duck_array_ops.py index c032a781e47..e12798b70c9 100644 --- a/xarray/tests/test_duck_array_ops.py +++ b/xarray/tests/test_duck_array_ops.py @@ -884,16 +884,18 @@ def test_push_dask(): import bottleneck import dask.array - array = np.array([np.nan, np.nan, np.nan, 1, 2, 3, np.nan, np.nan, 4, 5, np.nan, 6]) - expected = bottleneck.push(array, axis=0) - for c in range(1, 11): + array = np.array([np.nan, 1, 2, 3, np.nan, np.nan, np.nan, np.nan, 4, 5, np.nan, 6]) + + for n in [None, 1, 2, 3, 4, 5, 11]: + expected = bottleneck.push(array, axis=0, n=n) + for c in range(1, 11): + with raise_if_dask_computes(): + actual = push(dask.array.from_array(array, chunks=c), axis=0, n=n) + np.testing.assert_equal(actual, expected) + + # some chunks of size-1 with NaN with raise_if_dask_computes(): - actual = push(dask.array.from_array(array, chunks=c), axis=0, n=None) + actual = push( + dask.array.from_array(array, chunks=(1, 2, 3, 2, 2, 1, 1)), axis=0, n=n + ) np.testing.assert_equal(actual, expected) - - # some chunks of size-1 with NaN - with raise_if_dask_computes(): - actual = push( - dask.array.from_array(array, chunks=(1, 2, 3, 2, 2, 1, 1)), axis=0, n=None - ) - np.testing.assert_equal(actual, expected) diff --git a/xarray/tests/test_missing.py b/xarray/tests/test_missing.py index 69b59a7418c..4121b62a9e8 100644 --- a/xarray/tests/test_missing.py +++ b/xarray/tests/test_missing.py @@ -452,8 +452,10 @@ def test_ffill_bfill_dask(method): assert_equal(actual, expected) # limit < axis size - with pytest.raises(NotImplementedError): + with raise_if_dask_computes(): actual = dask_method("x", limit=2) + expected = numpy_method("x", limit=2) + assert_equal(actual, expected) # limit > axis size with raise_if_dask_computes(): From bc8d7f9e708dd510672fb52e402d988e4e6ee6d6 Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Mon, 3 Jan 2022 19:14:53 +0100 Subject: [PATCH 18/32] TST: check datetime converter is Matplotlibs (#6128) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- xarray/tests/test_plot.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/xarray/tests/test_plot.py b/xarray/tests/test_plot.py index 0052178ad68..b5cc334bcd0 100644 --- a/xarray/tests/test_plot.py +++ b/xarray/tests/test_plot.py @@ -2670,6 +2670,12 @@ def test_datetime_line_plot(self): # test if line plot raises no Exception self.darray.plot.line() + def test_datetime_units(self): + # test that matplotlib-native datetime works: + fig, ax = plt.subplots() + ax.plot(self.darray["time"], self.darray) + assert isinstance(ax.xaxis.get_major_locator(), mpl.dates.AutoDateLocator) + @pytest.mark.filterwarnings("ignore:setting an array element with a sequence") @requires_nc_time_axis From 0a40bf19536ec8b7e417e8085e384fb0208f06ba Mon Sep 17 00:00:00 2001 From: "Abel Aoun (he/him)" Date: Mon, 3 Jan 2022 22:35:01 +0100 Subject: [PATCH 19/32] DOC: Add "auto" to dataarray `chunk` method (#6068) * DOC: Uniformize DS and DA chunk method * DOC: Use literal instead of str Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- xarray/core/dataarray.py | 14 +++++++++++--- xarray/core/dataset.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index 0a99d898c3a..b8e63c9f2f7 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import sys import warnings from typing import ( TYPE_CHECKING, @@ -90,6 +91,12 @@ from .types import T_DataArray, T_Xarray +# TODO: Remove this check once python 3.7 is not supported: +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + def _infer_coords_and_dims( shape, coords, dims @@ -1096,6 +1103,7 @@ def chunk( self, chunks: Union[ int, + Literal["auto"], Tuple[int, ...], Tuple[Tuple[int, ...], ...], Mapping[Any, Union[None, int, Tuple[int, ...]]], @@ -1116,9 +1124,9 @@ def chunk( Parameters ---------- - chunks : int, tuple of int or mapping of hashable to int, optional - Chunk sizes along each dimension, e.g., ``5``, ``(5, 5)`` or - ``{'x': 5, 'y': 5}``. + chunks : int, "auto", tuple of int or mapping of hashable to int, optional + Chunk sizes along each dimension, e.g., ``5``, ``"auto"``, ``(5, 5)`` or + ``{"x": 5, "y": 5}``. name_prefix : str, optional Prefix for the name of the new dask array. token : str, optional diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index b3761a7d2e4..9c1421d85c3 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -105,6 +105,12 @@ broadcast_variables, ) +# TODO: Remove this check once python 3.7 is not supported: +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + if TYPE_CHECKING: from ..backends import AbstractDataStore, ZarrStore from .dataarray import DataArray @@ -2140,7 +2146,7 @@ def chunk( self, chunks: Union[ int, - str, + Literal["auto"], Mapping[Any, Union[None, int, str, Tuple[int, ...]]], ] = {}, # {} even though it's technically unsafe, is being used intentionally here (#4667) name_prefix: str = "xarray-", @@ -2159,8 +2165,8 @@ def chunk( Parameters ---------- - chunks : int, 'auto' or mapping, optional - Chunk sizes along each dimension, e.g., ``5`` or + chunks : int, "auto" or mapping of hashable to int, optional + Chunk sizes along each dimension, e.g., ``5``, ``"auto"``, or ``{"x": 5, "y": 5}``. name_prefix : str, optional Prefix for the name of any new dask arrays. From 60754fdbc4ecd9eb3c0978e82635c6d43e8d485b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sta=C5=84czak?= Date: Tue, 4 Jan 2022 00:05:22 +0100 Subject: [PATCH 20/32] Check for just `...`, rather than `[...]` in `da.stack` (#6132) * Add tests for cases discussed in issue 6051 * Raise error if dims = ... --- xarray/core/dataset.py | 2 ++ xarray/tests/test_dataarray.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 9c1421d85c3..6996266e1e6 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -3870,6 +3870,8 @@ def reorder_levels( return self._replace(variables, indexes=indexes) def _stack_once(self, dims, new_dim): + if dims == ...: + raise ValueError("Please use [...] for dims, rather than just ...") if ... in dims: dims = list(infix_dims(dims, self.dims)) variables = {} diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py index d2ce59cbced..b99846ef087 100644 --- a/xarray/tests/test_dataarray.py +++ b/xarray/tests/test_dataarray.py @@ -6678,3 +6678,17 @@ def test_from_pint_wrapping_dask(self): expected = xr.DataArray(arr, dims="x", coords={"lat": ("x", arr * 2)}) assert_identical(result, expected) np.testing.assert_equal(da.to_numpy(), arr) + + +class TestStackEllipsis: + # https://github.com/pydata/xarray/issues/6051 + def test_result_as_expected(self): + da = DataArray([[1, 2], [1, 2]], dims=("x", "y")) + result = da.stack(flat=[...]) + expected = da.stack(flat=da.dims) + assert_identical(result, expected) + + def test_error_on_ellipsis_without_list(self): + da = DataArray([[1, 2], [1, 2]], dims=("x", "y")) + with pytest.raises(ValueError): + da.stack(flat=...) From e05edea466cc775d834037e862c8a140a11ac9fe Mon Sep 17 00:00:00 2001 From: code-review-doctor <72647856+code-review-doctor@users.noreply.github.com> Date: Wed, 5 Jan 2022 09:37:37 +0000 Subject: [PATCH 21/32] remove paren from data that is fed to 1D DataArray (#6139) --- xarray/tests/test_dataarray.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py index b99846ef087..3897530816f 100644 --- a/xarray/tests/test_dataarray.py +++ b/xarray/tests/test_dataarray.py @@ -3071,7 +3071,7 @@ def test_to_and_from_dict(self): DataArray.from_dict(d) # this one is missing some necessary information - d = {"dims": ("t")} + d = {"dims": "t"} with pytest.raises( ValueError, match=r"cannot convert dict without the key 'data'" ): From 51d4d5ff2e490fdab5c2881b31fc395f9b86612b Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Wed, 5 Jan 2022 09:57:31 -0700 Subject: [PATCH 22/32] Revert "Deprecate bool(ds) (#6126)" (#6141) --- doc/whats-new.rst | 5 ----- xarray/core/dataset.py | 8 -------- xarray/tests/test_dataset.py | 4 +--- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index c63470535b1..f8fe939b849 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -40,11 +40,6 @@ Deprecations By `Tom Nicholas `_. -- Coercing a dataset to bool, e.g. ``bool(ds)``, is being deprecated and will raise an - error in a future version (not yet planned). For now, invoking ``Dataset.__bool__`` - issues a ``PendingDeprecationWarning`` (:issue:`6124`, :pull:`6126`). - By `Michael Delgado `_. - Bug fixes ~~~~~~~~~ - Properly support :py:meth:`DataArray.ffill`, :py:meth:`DataArray.bfill`, :py:meth:`Dataset.ffill` and :py:meth:`Dataset.bfill` along chunked dimensions (:issue:`6112`). diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py index 6996266e1e6..22f4e32e83e 100644 --- a/xarray/core/dataset.py +++ b/xarray/core/dataset.py @@ -1456,14 +1456,6 @@ def __len__(self) -> int: return len(self.data_vars) def __bool__(self) -> bool: - warnings.warn( - "coercing a Dataset to a bool will be deprecated. " - "Using bool(ds.data_vars) to check for at least one " - "data variable or using Dataset.to_array to test " - "whether array values are true is encouraged.", - PendingDeprecationWarning, - stacklevel=2, - ) return bool(self.data_vars) def __iter__(self) -> Iterator[Hashable]: diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py index 40b9b31c7fa..c8770601c30 100644 --- a/xarray/tests/test_dataset.py +++ b/xarray/tests/test_dataset.py @@ -544,9 +544,7 @@ def test_properties(self): assert "aasldfjalskdfj" not in ds.variables assert "dim1" in repr(ds.variables) assert len(ds) == 3 - - with pytest.warns(PendingDeprecationWarning): - assert bool(ds) + assert bool(ds) assert list(ds.data_vars) == ["var1", "var2", "var3"] assert list(ds.data_vars.keys()) == ["var1", "var2", "var3"] From e056cacdca55cc9d9118c830ca622ea965ebcdef Mon Sep 17 00:00:00 2001 From: code-review-doctor <72647856+code-review-doctor@users.noreply.github.com> Date: Wed, 5 Jan 2022 18:14:54 +0000 Subject: [PATCH 23/32] Remove paren from DataArray.from_dict docstring (#6140) --- xarray/core/dataarray.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index b8e63c9f2f7..aa621a3c428 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -2887,7 +2887,7 @@ def from_dict(cls, d: dict) -> "DataArray": .. code:: python - d = {"dims": ("t"), "data": x} + d = {"dims": "t", "data": x} d = { "coords": {"t": {"dims": "t", "data": t, "attrs": {"units": "s"}}}, From a98309c6849b5c2d7bcb31fcf3751c6abfaee792 Mon Sep 17 00:00:00 2001 From: Matthew Roeschke Date: Sat, 8 Jan 2022 21:15:11 -0800 Subject: [PATCH 24/32] Remove pd.Panel checks (#6145) * Remove pd.Panel checks * Remove unused imports * Add whatsnew --- doc/whats-new.rst | 3 +++ xarray/core/dataarray.py | 3 --- xarray/core/merge.py | 4 ++-- xarray/core/pdcompat.py | 14 -------------- 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index f8fe939b849..026d8a370d2 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -61,6 +61,9 @@ Internal Changes - Replace ``distutils.version`` with ``packaging.version`` (:issue:`6092`). By `Mathias Hauser `_. +- Removed internal checks for ``pd.Panel`` (:issue:`6145`). + By `Matthew Roeschke `_. + .. _whats-new.0.20.2: diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index aa621a3c428..b3ed6be94c9 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -32,7 +32,6 @@ groupby, indexing, ops, - pdcompat, resample, rolling, utils, @@ -400,8 +399,6 @@ def __init__( coords = [data.index, data.columns] elif isinstance(data, (pd.Index, IndexVariable)): coords = [data] - elif isinstance(data, pdcompat.Panel): - coords = [data.items, data.major_axis, data.minor_axis] if dims is None: dims = getattr(data, "dims", getattr(coords, "dims", None)) diff --git a/xarray/core/merge.py b/xarray/core/merge.py index a89e767826d..460e02ae10f 100644 --- a/xarray/core/merge.py +++ b/xarray/core/merge.py @@ -19,7 +19,7 @@ import pandas as pd -from . import dtypes, pdcompat +from . import dtypes from .alignment import deep_align from .duck_array_ops import lazy_array_equiv from .indexes import Index, PandasIndex @@ -45,7 +45,7 @@ CoercibleMapping = Union[Dataset, Mapping[Any, CoercibleValue]] -PANDAS_TYPES = (pd.Series, pd.DataFrame, pdcompat.Panel) +PANDAS_TYPES = (pd.Series, pd.DataFrame) _VALID_COMPAT = Frozen( { diff --git a/xarray/core/pdcompat.py b/xarray/core/pdcompat.py index 18153e2ecad..fb4d951888b 100644 --- a/xarray/core/pdcompat.py +++ b/xarray/core/pdcompat.py @@ -1,6 +1,3 @@ -# The remove_unused_levels defined here was copied based on the source code -# defined in pandas.core.indexes.muli.py - # For reference, here is a copy of the pandas copyright notice: # (c) 2011-2012, Lambda Foundry, Inc. and PyData Development Team @@ -37,17 +34,6 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import pandas as pd -from packaging.version import Version - -# allow ourselves to type checks for Panel even after it's removed -if Version(pd.__version__) < Version("0.25.0"): - Panel = pd.Panel -else: - - class Panel: # type: ignore[no-redef] - pass - def count_not_none(*args) -> int: """Compute the number of non-None arguments. From e7285ebc33561360dffe9af4f2db21808529dd17 Mon Sep 17 00:00:00 2001 From: Illviljan <14371165+Illviljan@users.noreply.github.com> Date: Sun, 9 Jan 2022 21:33:28 +0100 Subject: [PATCH 25/32] Remove registration of pandas datetime converter in plotting (#6109) Co-authored-by: Mathias Hauser Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- doc/whats-new.rst | 2 ++ xarray/plot/utils.py | 15 ++------------- xarray/tests/test_plot.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 026d8a370d2..f41e57989be 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -29,6 +29,8 @@ New Features Breaking changes ~~~~~~~~~~~~~~~~ +- Rely on matplotlib's default datetime converters instead of pandas' (:issue:`6102`, :pull:`6109`). + By `Jimmy Westling `_. - Improve repr readability when there are a large number of dimensions in datasets or dataarrays by wrapping the text once the maximum display width has been exceeded. (:issue: `5546`, :pull:`5662`) By `Jimmy Westling `_. diff --git a/xarray/plot/utils.py b/xarray/plot/utils.py index 9e7e78f4c44..3b2a133b3e5 100644 --- a/xarray/plot/utils.py +++ b/xarray/plot/utils.py @@ -28,20 +28,9 @@ ROBUST_PERCENTILE = 2.0 -_registered = False - - -def register_pandas_datetime_converter_if_needed(): - # based on https://github.com/pandas-dev/pandas/pull/17710 - global _registered - if not _registered: - pd.plotting.register_matplotlib_converters() - _registered = True - - def import_matplotlib_pyplot(): - """Import pyplot as register appropriate converters.""" - register_pandas_datetime_converter_if_needed() + """import pyplot""" + # TODO: This function doesn't do anything (after #6109), remove it? import matplotlib.pyplot as plt return plt diff --git a/xarray/tests/test_plot.py b/xarray/tests/test_plot.py index b5cc334bcd0..3088b7e109c 100644 --- a/xarray/tests/test_plot.py +++ b/xarray/tests/test_plot.py @@ -2674,7 +2674,37 @@ def test_datetime_units(self): # test that matplotlib-native datetime works: fig, ax = plt.subplots() ax.plot(self.darray["time"], self.darray) - assert isinstance(ax.xaxis.get_major_locator(), mpl.dates.AutoDateLocator) + + # Make sure only mpl converters are used, use type() so only + # mpl.dates.AutoDateLocator passes and no other subclasses: + assert type(ax.xaxis.get_major_locator()) is mpl.dates.AutoDateLocator + + def test_datetime_plot1d(self): + # Test that matplotlib-native datetime works: + p = self.darray.plot.line() + ax = p[0].axes + + # Make sure only mpl converters are used, use type() so only + # mpl.dates.AutoDateLocator passes and no other subclasses: + assert type(ax.xaxis.get_major_locator()) is mpl.dates.AutoDateLocator + + def test_datetime_plot2d(self): + # Test that matplotlib-native datetime works: + da = DataArray( + np.arange(3 * 4).reshape(3, 4), + dims=("x", "y"), + coords={ + "x": [1, 2, 3], + "y": [np.datetime64(f"2000-01-{x:02d}") for x in range(1, 5)], + }, + ) + + p = da.plot.pcolormesh() + ax = p.axes + + # Make sure only mpl converters are used, use type() so only + # mpl.dates.AutoDateLocator passes and no other subclasses: + assert type(ax.xaxis.get_major_locator()) is mpl.dates.AutoDateLocator @pytest.mark.filterwarnings("ignore:setting an array element with a sequence") From 5b322c9ea18f560e35857edcb78efe4e4f323551 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Jan 2022 00:07:11 -0800 Subject: [PATCH 26/32] Bump pypa/gh-action-pypi-publish from 1.4.2 to 1.5.0 (#6147) --- .github/workflows/pypi-release.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi-release.yaml b/.github/workflows/pypi-release.yaml index 432aea8a375..7bc35952729 100644 --- a/.github/workflows/pypi-release.yaml +++ b/.github/workflows/pypi-release.yaml @@ -64,7 +64,7 @@ jobs: ls -ltrh dist - name: Publish package to TestPyPI if: github.event_name == 'push' - uses: pypa/gh-action-pypi-publish@v1.4.2 + uses: pypa/gh-action-pypi-publish@v1.5.0 with: user: __token__ password: ${{ secrets.TESTPYPI_TOKEN }} @@ -89,7 +89,7 @@ jobs: name: releases path: dist - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 + uses: pypa/gh-action-pypi-publish@v1.5.0 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 69dc2a0b76346b4f02c4e9423f3532c35025607a Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Mon, 10 Jan 2022 14:13:22 -0800 Subject: [PATCH 27/32] Change concat dims to be Hashable (#6121) * Change concat dims to be Hashable * Add Literal types & tests * Update xarray/tests/test_concat.py Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> * Update xarray/core/concat.py Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> * Update xarray/core/concat.py Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> * add annotations Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> --- xarray/core/concat.py | 34 +++++++++------ xarray/tests/test_concat.py | 86 +++++++++++++++++++++---------------- 2 files changed, 71 insertions(+), 49 deletions(-) diff --git a/xarray/core/concat.py b/xarray/core/concat.py index e26c1464f2d..3145b9de71a 100644 --- a/xarray/core/concat.py +++ b/xarray/core/concat.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import ( TYPE_CHECKING, Dict, @@ -12,6 +14,7 @@ ) import pandas as pd +from typing_extensions import Literal from . import dtypes, utils from .alignment import align @@ -24,14 +27,19 @@ from .dataarray import DataArray from .dataset import Dataset +compat_options = Literal[ + "identical", "equals", "broadcast_equals", "no_conflicts", "override" +] +concat_options = Literal["all", "minimal", "different"] + @overload def concat( objs: Iterable["Dataset"], - dim: Union[str, "DataArray", pd.Index], - data_vars: Union[str, List[str]] = "all", - coords: Union[str, List[str]] = "different", - compat: str = "equals", + dim: Hashable | "DataArray" | pd.Index, + data_vars: concat_options | List[Hashable] = "all", + coords: concat_options | List[Hashable] = "different", + compat: compat_options = "equals", positions: Optional[Iterable[int]] = None, fill_value: object = dtypes.NA, join: str = "outer", @@ -43,10 +51,10 @@ def concat( @overload def concat( objs: Iterable["DataArray"], - dim: Union[str, "DataArray", pd.Index], - data_vars: Union[str, List[str]] = "all", - coords: Union[str, List[str]] = "different", - compat: str = "equals", + dim: Hashable | "DataArray" | pd.Index, + data_vars: concat_options | List[Hashable] = "all", + coords: concat_options | List[Hashable] = "different", + compat: compat_options = "equals", positions: Optional[Iterable[int]] = None, fill_value: object = dtypes.NA, join: str = "outer", @@ -74,14 +82,14 @@ def concat( xarray objects to concatenate together. Each object is expected to consist of variables and coordinates with matching shapes except for along the concatenated dimension. - dim : str or DataArray or pandas.Index + dim : Hashable or DataArray or pandas.Index Name of the dimension to concatenate along. This can either be a new dimension name, in which case it is added along axis=0, or an existing dimension name, in which case the location of the dimension is unchanged. If dimension is provided as a DataArray or Index, its name is used as the dimension to concatenate along and the values are added as a coordinate. - data_vars : {"minimal", "different", "all"} or list of str, optional + data_vars : {"minimal", "different", "all"} or list of Hashable, optional These data variables will be concatenated together: * "minimal": Only data variables in which the dimension already appears are included. @@ -91,11 +99,11 @@ def concat( load the data payload of data variables into memory if they are not already loaded. * "all": All data variables will be concatenated. - * list of str: The listed data variables will be concatenated, in + * list of dims: The listed data variables will be concatenated, in addition to the "minimal" data variables. If objects are DataArrays, data_vars must be "all". - coords : {"minimal", "different", "all"} or list of str, optional + coords : {"minimal", "different", "all"} or list of Hashable, optional These coordinate variables will be concatenated together: * "minimal": Only coordinates in which the dimension already appears are included. @@ -106,7 +114,7 @@ def concat( loaded. * "all": All coordinate variables will be concatenated, except those corresponding to other dimensions. - * list of str: The listed coordinate variables will be concatenated, + * list of Hashable: The listed coordinate variables will be concatenated, in addition to the "minimal" coordinates. compat : {"identical", "equals", "broadcast_equals", "no_conflicts", "override"}, optional String indicating how to compare non-concatenated variables of the same name for diff --git a/xarray/tests/test_concat.py b/xarray/tests/test_concat.py index e049f843bed..a8d06188844 100644 --- a/xarray/tests/test_concat.py +++ b/xarray/tests/test_concat.py @@ -1,4 +1,5 @@ from copy import deepcopy +from typing import List import numpy as np import pandas as pd @@ -6,6 +7,7 @@ from xarray import DataArray, Dataset, Variable, concat from xarray.core import dtypes, merge +from xarray.core.concat import compat_options, concat_options from . import ( InaccessibleArray, @@ -17,7 +19,7 @@ from .test_dataset import create_test_data -def test_concat_compat(): +def test_concat_compat() -> None: ds1 = Dataset( { "has_x_y": (("y", "x"), [[1, 2]]), @@ -50,10 +52,10 @@ def test_concat_compat(): class TestConcatDataset: @pytest.fixture - def data(self): + def data(self) -> Dataset: return create_test_data().drop_dims("dim3") - def rectify_dim_order(self, data, dataset): + def rectify_dim_order(self, data, dataset) -> Dataset: # return a new dataset with all variable dimensions transposed into # the order in which they are found in `data` return Dataset( @@ -64,11 +66,11 @@ def rectify_dim_order(self, data, dataset): @pytest.mark.parametrize("coords", ["different", "minimal"]) @pytest.mark.parametrize("dim", ["dim1", "dim2"]) - def test_concat_simple(self, data, dim, coords): + def test_concat_simple(self, data, dim, coords) -> None: datasets = [g for _, g in data.groupby(dim, squeeze=False)] assert_identical(data, concat(datasets, dim, coords=coords)) - def test_concat_merge_variables_present_in_some_datasets(self, data): + def test_concat_merge_variables_present_in_some_datasets(self, data) -> None: # coordinates present in some datasets but not others ds1 = Dataset(data_vars={"a": ("y", [0.1])}, coords={"x": 0.1}) ds2 = Dataset(data_vars={"a": ("y", [0.2])}, coords={"z": 0.2}) @@ -84,7 +86,7 @@ def test_concat_merge_variables_present_in_some_datasets(self, data): expected = data.copy().assign(foo=data1.foo) assert_identical(expected, actual) - def test_concat_2(self, data): + def test_concat_2(self, data) -> None: dim = "dim2" datasets = [g for _, g in data.groupby(dim, squeeze=True)] concat_over = [k for k, v in data.coords.items() if dim in v.dims and k != dim] @@ -93,7 +95,7 @@ def test_concat_2(self, data): @pytest.mark.parametrize("coords", ["different", "minimal", "all"]) @pytest.mark.parametrize("dim", ["dim1", "dim2"]) - def test_concat_coords_kwarg(self, data, dim, coords): + def test_concat_coords_kwarg(self, data, dim, coords) -> None: data = data.copy(deep=True) # make sure the coords argument behaves as expected data.coords["extra"] = ("dim4", np.arange(3)) @@ -107,7 +109,7 @@ def test_concat_coords_kwarg(self, data, dim, coords): else: assert_equal(data["extra"], actual["extra"]) - def test_concat(self, data): + def test_concat(self, data) -> None: split_data = [ data.isel(dim1=slice(3)), data.isel(dim1=3), @@ -115,7 +117,7 @@ def test_concat(self, data): ] assert_identical(data, concat(split_data, "dim1")) - def test_concat_dim_precedence(self, data): + def test_concat_dim_precedence(self, data) -> None: # verify that the dim argument takes precedence over # concatenating dataset variables of the same name dim = (2 * data["dim1"]).rename("dim1") @@ -124,14 +126,23 @@ def test_concat_dim_precedence(self, data): expected["dim1"] = dim assert_identical(expected, concat(datasets, dim)) + def test_concat_data_vars_typing(self) -> None: + # Testing typing, can be removed if the next function works with annotations. + data = Dataset({"foo": ("x", np.random.randn(10))}) + objs: List[Dataset] = [data.isel(x=slice(5)), data.isel(x=slice(5, None))] + actual = concat(objs, dim="x", data_vars="minimal") + assert_identical(data, actual) + def test_concat_data_vars(self): + # TODO: annotating this func fails data = Dataset({"foo": ("x", np.random.randn(10))}) - objs = [data.isel(x=slice(5)), data.isel(x=slice(5, None))] + objs: List[Dataset] = [data.isel(x=slice(5)), data.isel(x=slice(5, None))] for data_vars in ["minimal", "different", "all", [], ["foo"]]: actual = concat(objs, dim="x", data_vars=data_vars) assert_identical(data, actual) def test_concat_coords(self): + # TODO: annotating this func fails data = Dataset({"foo": ("x", np.random.randn(10))}) expected = data.assign_coords(c=("x", [0] * 5 + [1] * 5)) objs = [ @@ -146,6 +157,7 @@ def test_concat_coords(self): concat(objs, dim="x", coords=coords) def test_concat_constant_index(self): + # TODO: annotating this func fails # GH425 ds1 = Dataset({"foo": 1.5}, {"y": 1}) ds2 = Dataset({"foo": 2.5}, {"y": 1}) @@ -158,7 +170,7 @@ def test_concat_constant_index(self): # "foo" has dimension "y" so minimal should concatenate it? concat([ds1, ds2], "new_dim", data_vars="minimal") - def test_concat_size0(self): + def test_concat_size0(self) -> None: data = create_test_data() split_data = [data.isel(dim1=slice(0, 0)), data] actual = concat(split_data, "dim1") @@ -167,7 +179,7 @@ def test_concat_size0(self): actual = concat(split_data[::-1], "dim1") assert_identical(data, actual) - def test_concat_autoalign(self): + def test_concat_autoalign(self) -> None: ds1 = Dataset({"foo": DataArray([1, 2], coords=[("x", [1, 2])])}) ds2 = Dataset({"foo": DataArray([1, 2], coords=[("x", [1, 3])])}) actual = concat([ds1, ds2], "y") @@ -183,6 +195,7 @@ def test_concat_autoalign(self): assert_identical(expected, actual) def test_concat_errors(self): + # TODO: annotating this func fails data = create_test_data() split_data = [data.isel(dim1=slice(3)), data.isel(dim1=slice(3, None))] @@ -222,7 +235,7 @@ def test_concat_errors(self): ): concat([Dataset({"x": 0}), Dataset({}, {"x": 1})], dim="z") - def test_concat_join_kwarg(self): + def test_concat_join_kwarg(self) -> None: ds1 = Dataset({"a": (("x", "y"), [[0]])}, coords={"x": [0], "y": [0]}) ds2 = Dataset({"a": (("x", "y"), [[0]])}, coords={"x": [1], "y": [0.0001]}) @@ -258,10 +271,10 @@ def test_concat_join_kwarg(self): actual = concat( [ds1.drop_vars("x"), ds2.drop_vars("x")], join="override", dim="y" ) - expected = Dataset( + expected2 = Dataset( {"a": (("x", "y"), np.array([0, 0], ndmin=2))}, coords={"y": [0, 0.0001]} ) - assert_identical(actual, expected) + assert_identical(actual, expected2) @pytest.mark.parametrize( "combine_attrs, var1_attrs, var2_attrs, expected_attrs, expect_exception", @@ -389,7 +402,7 @@ def test_concat_combine_attrs_kwarg_variables( assert_identical(actual, expected) - def test_concat_promote_shape(self): + def test_concat_promote_shape(self) -> None: # mixed dims within variables objs = [Dataset({}, {"x": 0}), Dataset({"x": [1]})] actual = concat(objs, "x") @@ -427,7 +440,7 @@ def test_concat_promote_shape(self): expected = Dataset({"z": (("x", "y"), [[-1], [1]])}, {"x": [0, 1], "y": [0]}) assert_identical(actual, expected) - def test_concat_do_not_promote(self): + def test_concat_do_not_promote(self) -> None: # GH438 objs = [ Dataset({"y": ("t", [1])}, {"x": 1, "t": [0]}), @@ -444,14 +457,14 @@ def test_concat_do_not_promote(self): with pytest.raises(ValueError): concat(objs, "t", coords="minimal") - def test_concat_dim_is_variable(self): + def test_concat_dim_is_variable(self) -> None: objs = [Dataset({"x": 0}), Dataset({"x": 1})] coord = Variable("y", [3, 4]) expected = Dataset({"x": ("y", [0, 1]), "y": [3, 4]}) actual = concat(objs, coord) assert_identical(actual, expected) - def test_concat_multiindex(self): + def test_concat_multiindex(self) -> None: x = pd.MultiIndex.from_product([[1, 2, 3], ["a", "b"]]) expected = Dataset({"x": x}) actual = concat( @@ -461,7 +474,7 @@ def test_concat_multiindex(self): assert isinstance(actual.x.to_index(), pd.MultiIndex) @pytest.mark.parametrize("fill_value", [dtypes.NA, 2, 2.0, {"a": 2, "b": 1}]) - def test_concat_fill_value(self, fill_value): + def test_concat_fill_value(self, fill_value) -> None: datasets = [ Dataset({"a": ("x", [2, 3]), "b": ("x", [-2, 1]), "x": [1, 2]}), Dataset({"a": ("x", [1, 2]), "b": ("x", [3, -1]), "x": [0, 1]}), @@ -487,7 +500,7 @@ def test_concat_fill_value(self, fill_value): @pytest.mark.parametrize("dtype", [str, bytes]) @pytest.mark.parametrize("dim", ["x1", "x2"]) - def test_concat_str_dtype(self, dtype, dim): + def test_concat_str_dtype(self, dtype, dim) -> None: data = np.arange(4).reshape([2, 2]) @@ -511,7 +524,7 @@ def test_concat_str_dtype(self, dtype, dim): class TestConcatDataArray: - def test_concat(self): + def test_concat(self) -> None: ds = Dataset( { "foo": (["x", "y"], np.random.random((2, 3))), @@ -538,13 +551,13 @@ def test_concat(self): stacked = concat(grouped, pd.Index(ds["x"], name="x")) assert_identical(foo, stacked) - actual = concat([foo[0], foo[1]], pd.Index([0, 1])).reset_coords(drop=True) + actual2 = concat([foo[0], foo[1]], pd.Index([0, 1])).reset_coords(drop=True) expected = foo[:2].rename({"x": "concat_dim"}) - assert_identical(expected, actual) + assert_identical(expected, actual2) - actual = concat([foo[0], foo[1]], [0, 1]).reset_coords(drop=True) + actual3 = concat([foo[0], foo[1]], [0, 1]).reset_coords(drop=True) expected = foo[:2].rename({"x": "concat_dim"}) - assert_identical(expected, actual) + assert_identical(expected, actual3) with pytest.raises(ValueError, match=r"not identical"): concat([foo, bar], dim="w", compat="identical") @@ -552,7 +565,7 @@ def test_concat(self): with pytest.raises(ValueError, match=r"not a valid argument"): concat([foo, bar], dim="w", data_vars="minimal") - def test_concat_encoding(self): + def test_concat_encoding(self) -> None: # Regression test for GH1297 ds = Dataset( { @@ -568,7 +581,7 @@ def test_concat_encoding(self): assert concat([ds, ds], dim="x").encoding == ds.encoding @requires_dask - def test_concat_lazy(self): + def test_concat_lazy(self) -> None: import dask.array as da arrays = [ @@ -583,7 +596,7 @@ def test_concat_lazy(self): assert combined.dims == ("z", "x", "y") @pytest.mark.parametrize("fill_value", [dtypes.NA, 2, 2.0]) - def test_concat_fill_value(self, fill_value): + def test_concat_fill_value(self, fill_value) -> None: foo = DataArray([1, 2], coords=[("x", [1, 2])]) bar = DataArray([1, 2], coords=[("x", [1, 3])]) if fill_value == dtypes.NA: @@ -598,7 +611,7 @@ def test_concat_fill_value(self, fill_value): actual = concat((foo, bar), dim="y", fill_value=fill_value) assert_identical(actual, expected) - def test_concat_join_kwarg(self): + def test_concat_join_kwarg(self) -> None: ds1 = Dataset( {"a": (("x", "y"), [[0]])}, coords={"x": [0], "y": [0]} ).to_array() @@ -634,7 +647,7 @@ def test_concat_join_kwarg(self): actual = concat([ds1, ds2], join=join, dim="x") assert_equal(actual, expected[join].to_array()) - def test_concat_combine_attrs_kwarg(self): + def test_concat_combine_attrs_kwarg(self) -> None: da1 = DataArray([0], coords=[("x", [0])], attrs={"b": 42}) da2 = DataArray([0], coords=[("x", [1])], attrs={"b": 42, "c": 43}) @@ -660,7 +673,7 @@ def test_concat_combine_attrs_kwarg(self): @pytest.mark.parametrize("dtype", [str, bytes]) @pytest.mark.parametrize("dim", ["x1", "x2"]) - def test_concat_str_dtype(self, dtype, dim): + def test_concat_str_dtype(self, dtype, dim) -> None: data = np.arange(4).reshape([2, 2]) @@ -678,7 +691,7 @@ def test_concat_str_dtype(self, dtype, dim): assert np.issubdtype(actual.x2.dtype, dtype) - def test_concat_coord_name(self): + def test_concat_coord_name(self) -> None: da = DataArray([0], dims="a") da_concat = concat([da, da], dim=DataArray([0, 1], dims="b")) @@ -690,7 +703,7 @@ def test_concat_coord_name(self): @pytest.mark.parametrize("attr1", ({"a": {"meta": [10, 20, 30]}}, {"a": [1, 2, 3]}, {})) @pytest.mark.parametrize("attr2", ({"a": [1, 2, 3]}, {})) -def test_concat_attrs_first_variable(attr1, attr2): +def test_concat_attrs_first_variable(attr1, attr2) -> None: arrs = [ DataArray([[1], [2]], dims=["x", "y"], attrs=attr1), @@ -702,6 +715,7 @@ def test_concat_attrs_first_variable(attr1, attr2): def test_concat_merge_single_non_dim_coord(): + # TODO: annotating this func fails da1 = DataArray([1, 2, 3], dims="x", coords={"x": [1, 2, 3], "y": 1}) da2 = DataArray([4, 5, 6], dims="x", coords={"x": [4, 5, 6]}) @@ -722,7 +736,7 @@ def test_concat_merge_single_non_dim_coord(): concat([da1, da2, da3], dim="x") -def test_concat_preserve_coordinate_order(): +def test_concat_preserve_coordinate_order() -> None: x = np.arange(0, 5) y = np.arange(0, 10) time = np.arange(0, 4) @@ -755,7 +769,7 @@ def test_concat_preserve_coordinate_order(): assert_identical(actual.coords[act], expected.coords[exp]) -def test_concat_typing_check(): +def test_concat_typing_check() -> None: ds = Dataset({"foo": 1}, {"bar": 2}) da = Dataset({"foo": 3}, {"bar": 4}).to_array(dim="foo") From 9226c7ac87b3eb246f7a7e49f8f0f23d68951624 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Mon, 10 Jan 2022 15:52:51 -0800 Subject: [PATCH 28/32] Remove numpy from mypy pre-commit (#6151) --- .pre-commit-config.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1007226d256..df5e4b26eae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: # - id: velin # args: ["--write", "--compact"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.930 + rev: v0.931 hooks: - id: mypy # `properies` & `asv_bench` are copied from setup.cfg. @@ -45,8 +45,6 @@ repos: types-PyYAML, types-pytz, typing-extensions==3.10.0.0, - # Dependencies that are typed - numpy, ] # run this occasionally, ref discussion https://github.com/pydata/xarray/pull/3194 # - repo: https://github.com/asottile/pyupgrade From 5c08ab296bf9bbcfb5bd3c262e3fdcce986d69ab Mon Sep 17 00:00:00 2001 From: Mark Harfouche Date: Tue, 11 Jan 2022 15:54:57 +0530 Subject: [PATCH 29/32] Use base ImportError not MoudleNotFoundError when trying to see if the (#6154) modules load --- xarray/backends/h5netcdf_.py | 5 ++++- xarray/backends/netCDF4_.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/xarray/backends/h5netcdf_.py b/xarray/backends/h5netcdf_.py index 28f50b05ad4..a52e539181f 100644 --- a/xarray/backends/h5netcdf_.py +++ b/xarray/backends/h5netcdf_.py @@ -35,7 +35,10 @@ import h5netcdf has_h5netcdf = True -except ModuleNotFoundError: +except ImportError: + # Except a base ImportError (not ModuleNotFoundError) to catch usecases + # where errors have mismatched versions of c-dependencies. This can happen + # when developers are making changes them. has_h5netcdf = False diff --git a/xarray/backends/netCDF4_.py b/xarray/backends/netCDF4_.py index 4536f74766c..bc28e89b018 100644 --- a/xarray/backends/netCDF4_.py +++ b/xarray/backends/netCDF4_.py @@ -33,7 +33,10 @@ import netCDF4 has_netcdf4 = True -except ModuleNotFoundError: +except ImportError: + # Except a base ImportError (not ModuleNotFoundError) to catch usecases + # where errors have mismatched versions of c-dependencies. This can happen + # when developers are making changes them. has_netcdf4 = False From aeb00f9da90e4485d2e94f6796c7dd96a2cb1278 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 11 Jan 2022 17:06:18 +0100 Subject: [PATCH 30/32] _season_from_months can now handle np.nan (#5876) * _season_from_months can now handle np.nan _season_from_months can now handle np.nan and values outside of [1,12] I passed these tests: def test_season(): months = np.array([ 1, 2, 3, 4, 5, np.nan]) assert ( _season_from_months(months) == np.array(['DJF', 'DJF', 'MAM', 'MAM', 'MAM', 'na']) ).all() months = np.array([ 1, 100, 3, 13, 0, -5]) assert ( _season_from_months(months) == np.array(['DJF', 'na', 'MAM', 'na', 'na', 'na']) ).all() months = np.array(range(1, 13)) assert ( _season_from_months(months) == np.array(['DJF', 'DJF', 'MAM', 'MAM', 'MAM', 'JJA', 'JJA', 'JJA', 'SON', 'SON', 'SON', 'DJF']) ).all() test_season() * Run black * Remove duplicated import * returns np.nan and removed the useless attribution returns np.nan and removed the useless attribution when month is not in [1,12] * added test * applied black recommendations * Apply suggestions from code review finally, NaT is output as a "nan" string Co-authored-by: Spencer Clark * update whatsnew * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Correction on the credits Co-authored-by: Spencer Clark Co-authored-by: Illviljan <14371165+Illviljan@users.noreply.github.com> Co-authored-by: Spencer Clark Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- doc/whats-new.rst | 4 ++++ xarray/core/accessor_dt.py | 15 +++++++++++++-- xarray/tests/test_accessor_dt.py | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index f41e57989be..379c2c9d947 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -53,6 +53,10 @@ Bug fixes - Fix applying function with non-xarray arguments using :py:func:`xr.map_blocks`. By `Cindy Chiao `_. +- `dt.season `_ can now handle NaN and NaT. (:pull:`5876`). + By `Pierre Loicq `_. + + Documentation ~~~~~~~~~~~~~ diff --git a/xarray/core/accessor_dt.py b/xarray/core/accessor_dt.py index 2a7b6200d3b..7f8bf79a50a 100644 --- a/xarray/core/accessor_dt.py +++ b/xarray/core/accessor_dt.py @@ -16,9 +16,20 @@ def _season_from_months(months): """Compute season (DJF, MAM, JJA, SON) from month ordinal""" # TODO: Move "season" accessor upstream into pandas - seasons = np.array(["DJF", "MAM", "JJA", "SON"]) + seasons = np.array(["DJF", "MAM", "JJA", "SON", "nan"]) months = np.asarray(months) - return seasons[(months // 3) % 4] + + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="invalid value encountered in floor_divide" + ) + warnings.filterwarnings( + "ignore", message="invalid value encountered in remainder" + ) + idx = (months // 3) % 4 + + idx[np.isnan(idx)] = 4 + return seasons[idx.astype(int)] def _access_through_cftimeindex(values, name): diff --git a/xarray/tests/test_accessor_dt.py b/xarray/tests/test_accessor_dt.py index b471bd2e267..e9278f1e918 100644 --- a/xarray/tests/test_accessor_dt.py +++ b/xarray/tests/test_accessor_dt.py @@ -222,6 +222,7 @@ def test_dask_accessor_method(self, method, parameters) -> None: def test_seasons(self) -> None: dates = pd.date_range(start="2000/01/01", freq="M", periods=12) + dates = dates.append(pd.Index([np.datetime64("NaT")])) dates = xr.DataArray(dates) seasons = xr.DataArray( [ @@ -237,6 +238,7 @@ def test_seasons(self) -> None: "SON", "SON", "DJF", + "nan", ] ) From fbd11bd4f904940181321cbdd4c536d118fd85c3 Mon Sep 17 00:00:00 2001 From: Illviljan <14371165+Illviljan@users.noreply.github.com> Date: Tue, 11 Jan 2022 22:22:46 +0100 Subject: [PATCH 31/32] Drop support for python 3.7 (#5892) * remove requirement for setuptools.pkg_resources Fixes #5676 Now depends on importlib-metadata for Python major version < 3.8 * doh * doh2 * doh3, no reraise for fallback version number * black formatting * ordering * all this lifetime wasted by fucking linting tools * precommit * remove python 3.7 as min supported version * remove 3.7 from min_deps * Update ci-additional.yaml * test increasing dask * test increasing numba * Update ci-additional.yaml * remove setuptools from CI * undo dask/numba tests * Remove importlib_metadata * remove 3.7 compats * Remove 3.7 compat * remove sys * Update options.py * Update whats-new.rst * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Run flaky/all but dask with latest python version * fix all_but_dask file as well * Update dataarray.py * Update dataarray.py * Update doc/whats-new.rst Co-authored-by: keewis * update the install guide * remove py37 only dependencies * don't install importlib-metadata and typing_extensions into min-deps envs * update the requirements file * add back the optional typing_extensions dependency Co-authored-by: Martin K. Scherer Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: keewis Co-authored-by: Keewis --- .github/workflows/ci-additional.yaml | 23 +++++++++++-------- .github/workflows/ci.yaml | 2 +- ci/requirements/environment-windows.yml | 1 - ci/requirements/environment.yml | 1 - ...bare-minimum.yml => py38-bare-minimum.yml} | 4 +--- ...min-all-deps.yml => py38-min-all-deps.yml} | 3 +-- ...all-but-dask.yml => py39-all-but-dask.yml} | 3 +-- doc/getting-started-guide/installing.rst | 10 ++++---- doc/whats-new.rst | 2 ++ requirements.txt | 6 ++--- setup.cfg | 5 +--- xarray/core/dataarray.py | 8 +------ xarray/core/npcompat.py | 6 +---- xarray/core/options.py | 11 +-------- 14 files changed, 29 insertions(+), 56 deletions(-) rename ci/requirements/{py37-bare-minimum.yml => py38-bare-minimum.yml} (73%) rename ci/requirements/{py37-min-all-deps.yml => py38-min-all-deps.yml} (96%) rename ci/requirements/{py38-all-but-dask.yml => py39-all-but-dask.yml} (95%) diff --git a/.github/workflows/ci-additional.yaml b/.github/workflows/ci-additional.yaml index 0b59e199b39..fac4bb133b1 100644 --- a/.github/workflows/ci-additional.yaml +++ b/.github/workflows/ci-additional.yaml @@ -40,10 +40,13 @@ jobs: os: ["ubuntu-latest"] env: [ - "py37-bare-minimum", - "py37-min-all-deps", - "py38-all-but-dask", - "py38-flaky", + # Minimum python version: + "py38-bare-minimum", + "py38-min-all-deps", + + # Latest python version: + "py39-all-but-dask", + "py39-flaky", ] steps: - uses: actions/checkout@v2 @@ -52,7 +55,7 @@ jobs: - name: Set environment variables run: | - if [[ ${{ matrix.env }} == "py38-flaky" ]] ; + if [[ ${{ matrix.env }} == "py39-flaky" ]] ; then echo "CONDA_ENV_FILE=ci/requirements/environment.yml" >> $GITHUB_ENV echo "PYTEST_EXTRA_FLAGS=--run-flaky --run-network-tests" >> $GITHUB_ENV @@ -75,7 +78,7 @@ jobs: mamba-version: "*" activate-environment: xarray-tests auto-update-conda: false - python-version: 3.8 + python-version: 3.9 use-only-tar-bz2: true - name: Install conda dependencies @@ -128,7 +131,7 @@ jobs: mamba-version: "*" activate-environment: xarray-tests auto-update-conda: false - python-version: "3.8" + python-version: "3.9" - name: Install conda dependencies run: | @@ -164,10 +167,10 @@ jobs: channel-priority: strict mamba-version: "*" auto-update-conda: false - python-version: "3.8" + python-version: "3.9" - name: minimum versions policy run: | mamba install -y pyyaml conda python-dateutil - python ci/min_deps_check.py ci/requirements/py37-bare-minimum.yml - python ci/min_deps_check.py ci/requirements/py37-min-all-deps.yml + python ci/min_deps_check.py ci/requirements/py38-bare-minimum.yml + python ci/min_deps_check.py ci/requirements/py38-min-all-deps.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 82e21a4f46c..447507ad25f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,7 @@ jobs: matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] # Bookend python versions - python-version: ["3.7", "3.9"] + python-version: ["3.8", "3.9"] steps: - uses: actions/checkout@v2 with: diff --git a/ci/requirements/environment-windows.yml b/ci/requirements/environment-windows.yml index 5056f1ed6fa..a9074b6c949 100644 --- a/ci/requirements/environment-windows.yml +++ b/ci/requirements/environment-windows.yml @@ -36,7 +36,6 @@ dependencies: - rasterio - scipy - seaborn - - setuptools - sparse - toolz - typing_extensions diff --git a/ci/requirements/environment.yml b/ci/requirements/environment.yml index 23f8b9ca7ee..890220b54fb 100644 --- a/ci/requirements/environment.yml +++ b/ci/requirements/environment.yml @@ -40,7 +40,6 @@ dependencies: - rasterio - scipy - seaborn - - setuptools - sparse - toolz - typing_extensions diff --git a/ci/requirements/py37-bare-minimum.yml b/ci/requirements/py38-bare-minimum.yml similarity index 73% rename from ci/requirements/py37-bare-minimum.yml rename to ci/requirements/py38-bare-minimum.yml index 620b5057d50..c6e3ac504a8 100644 --- a/ci/requirements/py37-bare-minimum.yml +++ b/ci/requirements/py38-bare-minimum.yml @@ -3,7 +3,7 @@ channels: - conda-forge - nodefaults dependencies: - - python=3.7 + - python=3.8 - coveralls - pip - pytest @@ -12,5 +12,3 @@ dependencies: - pytest-xdist - numpy=1.18 - pandas=1.1 - - typing_extensions=3.7 - - importlib-metadata=2.0 diff --git a/ci/requirements/py37-min-all-deps.yml b/ci/requirements/py38-min-all-deps.yml similarity index 96% rename from ci/requirements/py37-min-all-deps.yml rename to ci/requirements/py38-min-all-deps.yml index 501942a214e..a6459b92ccb 100644 --- a/ci/requirements/py37-min-all-deps.yml +++ b/ci/requirements/py38-min-all-deps.yml @@ -7,7 +7,7 @@ dependencies: # Run ci/min_deps_check.py to verify that this file respects the policy. # When upgrading python, numpy, or pandas, must also change # doc/installing.rst and setup.py. - - python=3.7 + - python=3.8 - boto3=1.13 - bottleneck=1.3 # cartopy 0.18 conflicts with pynio @@ -24,7 +24,6 @@ dependencies: - hdf5=1.10 - hypothesis - iris=2.4 - - importlib-metadata=2.0 - lxml=4.6 # Optional dep of pydap - matplotlib-base=3.3 - nc-time-axis=1.2 diff --git a/ci/requirements/py38-all-but-dask.yml b/ci/requirements/py39-all-but-dask.yml similarity index 95% rename from ci/requirements/py38-all-but-dask.yml rename to ci/requirements/py39-all-but-dask.yml index 688dfb7a2bc..21217e79c7c 100644 --- a/ci/requirements/py38-all-but-dask.yml +++ b/ci/requirements/py39-all-but-dask.yml @@ -3,7 +3,7 @@ channels: - conda-forge - nodefaults dependencies: - - python=3.8 + - python=3.9 - black - aiobotocore - boto3 @@ -36,7 +36,6 @@ dependencies: - rasterio - scipy - seaborn - - setuptools - sparse - toolz - typing_extensions diff --git a/doc/getting-started-guide/installing.rst b/doc/getting-started-guide/installing.rst index 050e837f2e3..6f437a2dc4c 100644 --- a/doc/getting-started-guide/installing.rst +++ b/doc/getting-started-guide/installing.rst @@ -6,11 +6,9 @@ Installation Required dependencies --------------------- -- Python (3.7 or later) -- `importlib_metadata `__ (1.4 or later, Python 3.7 only) -- ``typing_extensions`` (3.7 or later, Python 3.7 only) -- `numpy `__ (1.17 or later) -- `pandas `__ (1.0 or later) +- Python (3.8 or later) +- `numpy `__ (1.18 or later) +- `pandas `__ (1.1 or later) .. _optional-dependencies: @@ -103,7 +101,7 @@ release is guaranteed to work. You can see the actual minimum tested versions: -``_ +``_ .. _installation-instructions: diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 379c2c9d947..12e931c0d63 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -40,6 +40,8 @@ Deprecations ~~~~~~~~~~~~ - Removed the lock kwarg from the zarr and pydap backends, completing the deprecation cycle started in :issue:`5256`. By `Tom Nicholas `_. +- Support for ``python 3.7`` has been dropped. (:pull:`5892`) + By `Jimmy Westling `_. Bug fixes diff --git a/requirements.txt b/requirements.txt index 0fa83c8ccc1..729a3655125 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,5 @@ # it exists to let GitHub build the repository dependency graph # https://help.github.com/en/github/visualizing-repository-data-with-graphs/listing-the-packages-that-a-repository-depends-on -numpy >= 1.17 -pandas >= 1.0 -setuptools >= 40.4 -typing-extensions >= 3.7 +numpy >= 1.18 +pandas >= 1.1 diff --git a/setup.cfg b/setup.cfg index 38aaf6f7467..f9e0afa6445 100644 --- a/setup.cfg +++ b/setup.cfg @@ -64,7 +64,6 @@ classifiers = Intended Audience :: Science/Research Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -74,12 +73,10 @@ classifiers = packages = find: zip_safe = False # https://mypy.readthedocs.io/en/latest/installed_packages.html include_package_data = True -python_requires = >=3.7 +python_requires = >=3.8 install_requires = numpy >= 1.18 pandas >= 1.1 - importlib-metadata; python_version < '3.8' - typing_extensions >= 3.7; python_version < '3.8' [options.extras_require] io = diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py index b3ed6be94c9..105271cef61 100644 --- a/xarray/core/dataarray.py +++ b/xarray/core/dataarray.py @@ -1,7 +1,6 @@ from __future__ import annotations import datetime -import sys import warnings from typing import ( TYPE_CHECKING, @@ -11,6 +10,7 @@ Hashable, Iterable, List, + Literal, Mapping, Optional, Sequence, @@ -90,12 +90,6 @@ from .types import T_DataArray, T_Xarray -# TODO: Remove this check once python 3.7 is not supported: -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - def _infer_coords_and_dims( shape, coords, dims diff --git a/xarray/core/npcompat.py b/xarray/core/npcompat.py index b22b0777f99..1eaa2728e8a 100644 --- a/xarray/core/npcompat.py +++ b/xarray/core/npcompat.py @@ -28,7 +28,6 @@ # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import sys from typing import TYPE_CHECKING, Any, Sequence, TypeVar, Union import numpy as np @@ -39,10 +38,7 @@ from numpy.typing import ArrayLike, DTypeLike except ImportError: # fall back for numpy < 1.20, ArrayLike adapted from numpy.typing._array_like - if sys.version_info >= (3, 8): - from typing import Protocol - else: - from typing_extensions import Protocol + from typing import Protocol if TYPE_CHECKING: diff --git a/xarray/core/options.py b/xarray/core/options.py index 90018c51807..0c45e126fe6 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -1,17 +1,8 @@ -import sys import warnings +from typing import TYPE_CHECKING, Literal, TypedDict, Union from .utils import FrozenDict -# TODO: Remove this check once python 3.7 is not supported: -if sys.version_info >= (3, 8): - from typing import TYPE_CHECKING, Literal, TypedDict, Union -else: - from typing import TYPE_CHECKING, Union - - from typing_extensions import Literal, TypedDict - - if TYPE_CHECKING: try: from matplotlib.colors import Colormap From 18703bafe3fa712c537ad18bca904655e58dbe7e Mon Sep 17 00:00:00 2001 From: Illviljan <14371165+Illviljan@users.noreply.github.com> Date: Tue, 11 Jan 2022 22:59:16 +0100 Subject: [PATCH 32/32] Small typing fix (#6159) --- xarray/core/concat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xarray/core/concat.py b/xarray/core/concat.py index 3145b9de71a..7ead1918e1a 100644 --- a/xarray/core/concat.py +++ b/xarray/core/concat.py @@ -6,6 +6,7 @@ Hashable, Iterable, List, + Literal, Optional, Set, Tuple, @@ -14,7 +15,6 @@ ) import pandas as pd -from typing_extensions import Literal from . import dtypes, utils from .alignment import align