Repository: pydata/xarray

Issue:
sparse and other duck array issues
Issues I've run into working with @friedrichknuth at the Pangeo meeting trying to use sparse

1. `.plot()` doesn't work. We need to convert to dense before plotting
2. `.values` doesn't work. Raises an error about explicitly calling `todense()`
3. `align` doesn't seem to work.

Discussion:
github-actions[bot]: ## Unit Test Results
0 files  0 suites   0s :stopwatch:
0 tests 0 :heavy_check_mark: 0 :zzz: 0 :x:

Results for commit 17c57556.

TomNicholas: Irritating linting error:

```
xarray/core/variable.py:1095: error: Incompatible return value type (got "Variable", expected "VariableType")  [return-value]
xarray/core/dataarray.py:661: error: Incompatible return value type (got "DataArray", expected "T_DataArray")  [return-value]
Found 2 errors in 2 files (checked 143 source files)
```
max-sixty: @TomNicholas I think that should fix it. But it's become Haskell-esque!
pep8speaks: Hello @TomNicholas! Thanks for updating this PR. We checked the lines you've touched for [PEP 8](https://www.python.org/dev/peps/pep-0008) issues, and found:



* In the file [`xarray/core/dataset.py`](https://github.com/pydata/xarray/blob/1215e6917bfffc1c980df0f4399b2f5fa696c289/xarray/core/dataset.py):

> [Line 1203:5](https://github.com/pydata/xarray/blob/1215e6917bfffc1c980df0f4399b2f5fa696c289/xarray/core/dataset.py#L1203): [E303](https://duckduckgo.com/?q=pep8%20E303) too many blank lines (3)






TomNicholas: This is actually ready for a review now @shoyer 
TomNicholas: The only failure is the same as in https://github.com/pydata/xarray/issues/5600 , so I think this should be ready to merge.
TomNicholas: @dcherian I think `.to_numpy()` does actually work in the case of an
IndexVariable, because the underlying pandas index has that method. That's
not going to be used yet, but we should perhaps leave it in to prepare for
the flexible indexes?

Other than that I like your other suggestions, please commit and merge as
you see fit!


On Fri, 16 Jul 2021, 10:44 Deepak Cherian, ***@***.***> wrote:

> ***@***.**** approved this pull request.
>
> looks great. Thanks @TomNicholas <https://github.com/TomNicholas>
> ------------------------------
>
> In xarray/core/dataarray.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671384844>:
>
> > @@ -623,7 +623,7 @@ def __len__(self) -> int:
>
>
>
>      @property
>
>      def data(self) -> Any:
>
> -        """The array's data as a dask or numpy array"""
>
> +        """The array's data as a numpy-like array"""
>
>
> ⬇️ Suggested change
>
> -        """The array's data as a numpy-like array"""
>
> +        """
>
> +        The DataArray's data as an array. The underlying array type
>
> +        (e.g. dask, sparse, pint) is preserved.
>
> +
>
> +        See Also
>
> +        --------
>
> +        DataArray.to_numpy
>
> +        DataArray.as_numpy
>
> +        DataArray.values
>
> +        """
>
>
> ------------------------------
>
> In xarray/core/dataarray.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671385021>:
>
> > @@ -632,13 +632,42 @@ def data(self, value: Any) -> None:
>
>
>
>      @property
>
>      def values(self) -> np.ndarray:
>
> -        """The array's data as a numpy.ndarray"""
>
> +        """
>
> +        The array's data as a numpy.ndarray.
>
> +
>
> +        If the array's data is not a numpy.ndarray this will attempt to convert
>
> +        it naively using np.array(), which will raise an error if the array
>
> +        type does not support coercion like this.
>
>
> ⬇️ Suggested change
>
> -        type does not support coercion like this.
>
> +        type does not support coercion like this (e.g. cupy).
>
>
> ------------------------------
>
> In xarray/core/dataarray.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671385936>:
>
> >          return self.variable.values
>
>
>
>      @values.setter
>
>      def values(self, value: Any) -> None:
>
>          self.variable.values = value
>
>
>
> +    def to_numpy(self) -> np.ndarray:
>
> +        """
>
> +        Coerces wrapped data to numpy and returns a numpy.ndarray.
>
> +
>
> +        See also
>
> +        --------
>
> +        DataArray.as_numpy : Same but returns the surrounding DataArray instead.
>
> +        Dataset.as_numpy
>
>
> ⬇️ Suggested change
>
> -        Dataset.as_numpy
>
> +        Dataset.as_numpy
>
> +        DataArray.values
>
> +        DataArray.data
>
>
> ------------------------------
>
> In xarray/core/dataarray.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671386127>:
>
> > +
>
> +        See also
>
> +        --------
>
> +        DataArray.as_numpy : Same but returns the surrounding DataArray instead.
>
> +        Dataset.as_numpy
>
> +        """
>
> +        return self.variable.to_numpy()
>
> +
>
> +    def as_numpy(self: T_DataArray) -> T_DataArray:
>
> +        """
>
> +        Coerces wrapped data and coordinates into numpy arrays, returning a DataArray.
>
> +
>
> +        See also
>
> +        --------
>
> +        DataArray.to_numpy : Same but returns only the data as a numpy.ndarray object.
>
> +        Dataset.as_numpy : Converts all variables in a Dataset.
>
>
> ⬇️ Suggested change
>
> -        Dataset.as_numpy : Converts all variables in a Dataset.
>
> +        Dataset.as_numpy : Converts all variables in a Dataset.
>
> +        DataArray.values
>
> +        DataArray.data
>
>
> ------------------------------
>
> In xarray/core/pycompat.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671387856>:
>
> >
>
> +        self.module = duck_array_module
>
> +        self.version = duck_array_version
>
> +        self.type = duck_array_type
>
> +        self.available = self.version > "0.0.0"
>
>
> ⬇️ Suggested change
>
> -        self.available = self.version > "0.0.0"
>
> +        self.available = duck_array_module is not None
>
>
> v. minor: this seems cleaner?
> ------------------------------
>
> In xarray/core/variable.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671388121>:
>
> > +dask_array_type = DuckArrayModule("dask").type
>
> +cupy_array_type = DuckArrayModule("cupy").type
>
> +sparse_array_type = DuckArrayModule("sparse").type
>
>
> import these from pycompat as earlier?
> ------------------------------
>
> In xarray/core/variable.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671388613>:
>
> > +        try:
>
> +            data = data.to_numpy()
>
> +        except AttributeError:
>
>
> Isn't this always failing? Should we avoid it until such a protocol
> actually exists?
> ------------------------------
>
> In xarray/core/variable.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671389110>:
>
> > @@ -1069,6 +1069,32 @@ def chunk(self, chunks={}, name=None, lock=False):
>
>
>
>          return self._replace(data=data)
>
>
>
> +    def to_numpy(self) -> np.ndarray:
>
> +        """Coerces wrapped data to numpy and returns a numpy.ndarray"""
>
> +        # TODO an entrypoint so array libraries can choose coercion method?
>
> +        data = self.data
>
> +        try:
>
> +            data = data.to_numpy()
>
> +        except AttributeError:
>
> +            if isinstance(data, dask_array_type):
>
> +                data = self.compute().data
>
>
> ⬇️ Suggested change
>
> -                data = self.compute().data
>
> +                data = data.compute()
>
>
> to match the rest?
> ------------------------------
>
> In xarray/tests/test_variable.py
> <https://github.com/pydata/xarray/pull/5568#discussion_r671391347>:
>
> > @@ -2540,3 +2542,68 @@ def test_clip(var):
>
>              var.mean("z").data[:, :, np.newaxis],
>
>          ),
>
>      )
>
> +
>
> +
>
> ***@***.***("Var", [Variable, IndexVariable])
>
>
> you could stick these in VariableSubclassObjects? Could also do that
> later if it's a good idea
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/pydata/xarray/pull/5568#pullrequestreview-708566048>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AISNPIYHDVQ7DOWCTQH4REDTYBOW5ANCNFSM47XKDGNQ>
> .
>

TomNicholas: The only error is the ffspec error now
TomNicholas: Okay thanks @shoyer - I've removed the type check and the attempt to call `.to_numpy()`.

Patch:
diff --git a/doc/api.rst b/doc/api.rst
index bb3a99bf..0e34b13f 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -686,6 +686,7 @@ Dataset methods
    open_zarr
    Dataset.to_netcdf
    Dataset.to_pandas
+   Dataset.as_numpy
    Dataset.to_zarr
    save_mfdataset
    Dataset.to_array
@@ -716,6 +717,8 @@ DataArray methods
    DataArray.to_pandas
    DataArray.to_series
    DataArray.to_dataframe
+   DataArray.to_numpy
+   DataArray.as_numpy
    DataArray.to_index
    DataArray.to_masked_array
    DataArray.to_cdms2
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 8045e5d4..38f152fa 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -56,6 +56,8 @@ New Features
 - Allow removal of the coordinate attribute ``coordinates`` on variables by setting ``.attrs['coordinates']= None``
   (:issue:`5510`).
   By `Elle Smith <https://github.com/ellesmith88>`_.
+- Added :py:meth:`DataArray.to_numpy`, :py:meth:`DataArray.as_numpy`, and :py:meth:`Dataset.as_numpy`. (:pull:`5568`).
+  By `Tom Nicholas <https://github.com/TomNicholas>`_.
 
 Breaking changes
 ~~~~~~~~~~~~~~~~
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index b4d553c2..d567da62 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -426,12 +426,12 @@ class DataArray(AbstractArray, DataWithCoords, DataArrayArithmetic):
         self._close = None
 
     def _replace(
-        self,
+        self: T_DataArray,
         variable: Variable = None,
         coords=None,
         name: Union[Hashable, None, Default] = _default,
         indexes=None,
-    ) -> "DataArray":
+    ) -> T_DataArray:
         if variable is None:
             variable = self.variable
         if coords is None:
@@ -623,7 +623,16 @@ class DataArray(AbstractArray, DataWithCoords, DataArrayArithmetic):
 
     @property
     def data(self) -> Any:
-        """The array's data as a dask or numpy array"""
+        """
+        The DataArray's data as an array. The underlying array type
+        (e.g. dask, sparse, pint) is preserved.
+
+        See Also
+        --------
+        DataArray.to_numpy
+        DataArray.as_numpy
+        DataArray.values
+        """
         return self.variable.data
 
     @data.setter
@@ -632,13 +641,46 @@ class DataArray(AbstractArray, DataWithCoords, DataArrayArithmetic):
 
     @property
     def values(self) -> np.ndarray:
-        """The array's data as a numpy.ndarray"""
+        """
+        The array's data as a numpy.ndarray.
+
+        If the array's data is not a numpy.ndarray this will attempt to convert
+        it naively using np.array(), which will raise an error if the array
+        type does not support coercion like this (e.g. cupy).
+        """
         return self.variable.values
 
     @values.setter
     def values(self, value: Any) -> None:
         self.variable.values = value
 
+    def to_numpy(self) -> np.ndarray:
+        """
+        Coerces wrapped data to numpy and returns a numpy.ndarray.
+
+        See also
+        --------
+        DataArray.as_numpy : Same but returns the surrounding DataArray instead.
+        Dataset.as_numpy
+        DataArray.values
+        DataArray.data
+        """
+        return self.variable.to_numpy()
+
+    def as_numpy(self: T_DataArray) -> T_DataArray:
+        """
+        Coerces wrapped data and coordinates into numpy arrays, returning a DataArray.
+
+        See also
+        --------
+        DataArray.to_numpy : Same but returns only the data as a numpy.ndarray object.
+        Dataset.as_numpy : Converts all variables in a Dataset.
+        DataArray.values
+        DataArray.data
+        """
+        coords = {k: v.as_numpy() for k, v in self._coords.items()}
+        return self._replace(self.variable.as_numpy(), coords, indexes=self._indexes)
+
     @property
     def _in_memory(self) -> bool:
         return self.variable._in_memory
@@ -931,7 +973,7 @@ class DataArray(AbstractArray, DataWithCoords, DataArrayArithmetic):
         ds = self._to_temp_dataset().persist(**kwargs)
         return self._from_temp_dataset(ds)
 
-    def copy(self, deep: bool = True, data: Any = None) -> "DataArray":
+    def copy(self: T_DataArray, deep: bool = True, data: Any = None) -> T_DataArray:
         """Returns a copy of this array.
 
         If `deep=True`, a deep copy is made of the data array.
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index f3378ad9..650b3c80 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -1323,6 +1323,18 @@ class Dataset(DataWithCoords, DatasetArithmetic, Mapping):
 
         return self._replace(variables, attrs=attrs)
 
+    def as_numpy(self: "Dataset") -> "Dataset":
+        """
+        Coerces wrapped data and coordinates into numpy arrays, returning a Dataset.
+
+        See also
+        --------
+        DataArray.as_numpy
+        DataArray.to_numpy : Returns only the data as a numpy.ndarray object.
+        """
+        numpy_variables = {k: v.as_numpy() for k, v in self.variables.items()}
+        return self._replace(variables=numpy_variables)
+
     @property
     def _level_coords(self) -> Dict[str, Hashable]:
         """Return a mapping of all MultiIndex levels and their corresponding
diff --git a/xarray/core/pycompat.py b/xarray/core/pycompat.py
index 9f47da6c..d1649235 100644
--- a/xarray/core/pycompat.py
+++ b/xarray/core/pycompat.py
@@ -1,4 +1,5 @@
 from distutils.version import LooseVersion
+from importlib import import_module
 
 import numpy as np
 
@@ -6,42 +7,57 @@ from .utils import is_duck_array
 
 integer_types = (int, np.integer)
 
-try:
-    import dask
-    import dask.array
-    from dask.base import is_dask_collection
 
-    dask_version = LooseVersion(dask.__version__)
+class DuckArrayModule:
+    """
+    Solely for internal isinstance and version checks.
 
-    # solely for isinstance checks
-    dask_array_type = (dask.array.Array,)
+    Motivated by having to only import pint when required (as pint currently imports xarray)
+    https://github.com/pydata/xarray/pull/5561#discussion_r664815718
+    """
 
-    def is_duck_dask_array(x):
-        return is_duck_array(x) and is_dask_collection(x)
+    def __init__(self, mod):
+        try:
+            duck_array_module = import_module(mod)
+            duck_array_version = LooseVersion(duck_array_module.__version__)
+
+            if mod == "dask":
+                duck_array_type = (import_module("dask.array").Array,)
+            elif mod == "pint":
+                duck_array_type = (duck_array_module.Quantity,)
+            elif mod == "cupy":
+                duck_array_type = (duck_array_module.ndarray,)
+            elif mod == "sparse":
+                duck_array_type = (duck_array_module.SparseArray,)
+            else:
+                raise NotImplementedError
+
+        except ImportError:  # pragma: no cover
+            duck_array_module = None
+            duck_array_version = LooseVersion("0.0.0")
+            duck_array_type = ()
 
+        self.module = duck_array_module
+        self.version = duck_array_version
+        self.type = duck_array_type
+        self.available = duck_array_module is not None
 
-except ImportError:  # pragma: no cover
-    dask_version = LooseVersion("0.0.0")
-    dask_array_type = ()
-    is_duck_dask_array = lambda _: False
-    is_dask_collection = lambda _: False
 
-try:
-    # solely for isinstance checks
-    import sparse
+def is_duck_dask_array(x):
+    if DuckArrayModule("dask").available:
+        from dask.base import is_dask_collection
+
+        return is_duck_array(x) and is_dask_collection(x)
+    else:
+        return False
+
 
-    sparse_version = LooseVersion(sparse.__version__)
-    sparse_array_type = (sparse.SparseArray,)
-except ImportError:  # pragma: no cover
-    sparse_version = LooseVersion("0.0.0")
-    sparse_array_type = ()
+dsk = DuckArrayModule("dask")
+dask_version = dsk.version
+dask_array_type = dsk.type
 
-try:
-    # solely for isinstance checks
-    import cupy
+sp = DuckArrayModule("sparse")
+sparse_array_type = sp.type
+sparse_version = sp.version
 
-    cupy_version = LooseVersion(cupy.__version__)
-    cupy_array_type = (cupy.ndarray,)
-except ImportError:  # pragma: no cover
-    cupy_version = LooseVersion("0.0.0")
-    cupy_array_type = ()
+cupy_array_type = DuckArrayModule("cupy").type
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
index f57c4495..3d9b5079 100644
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -29,10 +29,12 @@ from .indexes import PandasIndex, wrap_pandas_index
 from .indexing import BasicIndexer, OuterIndexer, VectorizedIndexer, as_indexable
 from .options import _get_keep_attrs
 from .pycompat import (
+    DuckArrayModule,
     cupy_array_type,
     dask_array_type,
     integer_types,
     is_duck_dask_array,
+    sparse_array_type,
 )
 from .utils import (
     NdimSizeLenMixin,
@@ -259,7 +261,7 @@ def _as_array_or_item(data):
 
     TODO: remove this (replace with np.asarray) once these issues are fixed
     """
-    data = data.get() if isinstance(data, cupy_array_type) else np.asarray(data)
+    data = np.asarray(data)
     if data.ndim == 0:
         if data.dtype.kind == "M":
             data = np.datetime64(data, "ns")
@@ -1069,6 +1071,29 @@ class Variable(AbstractArray, NdimSizeLenMixin, VariableArithmetic):
 
         return self._replace(data=data)
 
+    def to_numpy(self) -> np.ndarray:
+        """Coerces wrapped data to numpy and returns a numpy.ndarray"""
+        # TODO an entrypoint so array libraries can choose coercion method?
+        data = self.data
+        # TODO first attempt to call .to_numpy() once some libraries implement it
+        if isinstance(data, dask_array_type):
+            data = data.compute()
+        if isinstance(data, cupy_array_type):
+            data = data.get()
+        # pint has to be imported dynamically as pint imports xarray
+        pint_array_type = DuckArrayModule("pint").type
+        if isinstance(data, pint_array_type):
+            data = data.magnitude
+        if isinstance(data, sparse_array_type):
+            data = data.todense()
+        data = np.asarray(data)
+
+        return data
+
+    def as_numpy(self: VariableType) -> VariableType:
+        """Coerces wrapped data into a numpy array, returning a Variable."""
+        return self._replace(data=self.to_numpy())
+
     def _as_sparse(self, sparse_format=_default, fill_value=dtypes.NA):
         """
         use sparse-array as backend.


Test patch:
diff --git a/xarray/tests/__init__.py b/xarray/tests/__init__.py
index 9029dc1c..d757fb45 100644
--- a/xarray/tests/__init__.py
+++ b/xarray/tests/__init__.py
@@ -83,6 +83,7 @@ has_cfgrib, requires_cfgrib = _importorskip("cfgrib")
 has_numbagg, requires_numbagg = _importorskip("numbagg")
 has_seaborn, requires_seaborn = _importorskip("seaborn")
 has_sparse, requires_sparse = _importorskip("sparse")
+has_cupy, requires_cupy = _importorskip("cupy")
 has_cartopy, requires_cartopy = _importorskip("cartopy")
 # Need Pint 0.15 for __dask_tokenize__ tests for Quantity wrapped Dask Arrays
 has_pint_0_15, requires_pint_0_15 = _importorskip("pint", minversion="0.15")
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index b9f04085..79370adb 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -36,10 +36,12 @@ from xarray.tests import (
     has_dask,
     raise_if_dask_computes,
     requires_bottleneck,
+    requires_cupy,
     requires_dask,
     requires_iris,
     requires_numbagg,
     requires_numexpr,
+    requires_pint_0_15,
     requires_scipy,
     requires_sparse,
     source_ndarray,
@@ -7375,3 +7377,87 @@ def test_drop_duplicates(keep):
     expected = xr.DataArray(data, dims="time", coords={"time": time}, name="test")
     result = ds.drop_duplicates("time", keep=keep)
     assert_equal(expected, result)
+
+
+class TestNumpyCoercion:
+    # TODO once flexible indexes refactor complete also test coercion of dimension coords
+    def test_from_numpy(self):
+        da = xr.DataArray([1, 2, 3], dims="x", coords={"lat": ("x", [4, 5, 6])})
+
+        assert_identical(da.as_numpy(), da)
+        np.testing.assert_equal(da.to_numpy(), np.array([1, 2, 3]))
+        np.testing.assert_equal(da["lat"].to_numpy(), np.array([4, 5, 6]))
+
+    @requires_dask
+    def test_from_dask(self):
+        da = xr.DataArray([1, 2, 3], dims="x", coords={"lat": ("x", [4, 5, 6])})
+        da_chunked = da.chunk(1)
+
+        assert_identical(da_chunked.as_numpy(), da.compute())
+        np.testing.assert_equal(da.to_numpy(), np.array([1, 2, 3]))
+        np.testing.assert_equal(da["lat"].to_numpy(), np.array([4, 5, 6]))
+
+    @requires_pint_0_15
+    def test_from_pint(self):
+        from pint import Quantity
+
+        arr = np.array([1, 2, 3])
+        da = xr.DataArray(
+            Quantity(arr, units="Pa"),
+            dims="x",
+            coords={"lat": ("x", Quantity(arr + 3, units="m"))},
+        )
+
+        expected = xr.DataArray(arr, dims="x", coords={"lat": ("x", arr + 3)})
+        assert_identical(da.as_numpy(), expected)
+        np.testing.assert_equal(da.to_numpy(), arr)
+        np.testing.assert_equal(da["lat"].to_numpy(), arr + 3)
+
+    @requires_sparse
+    def test_from_sparse(self):
+        import sparse
+
+        arr = np.diagflat([1, 2, 3])
+        sparr = sparse.COO.from_numpy(arr)
+        da = xr.DataArray(
+            sparr, dims=["x", "y"], coords={"elev": (("x", "y"), sparr + 3)}
+        )
+
+        expected = xr.DataArray(
+            arr, dims=["x", "y"], coords={"elev": (("x", "y"), arr + 3)}
+        )
+        assert_identical(da.as_numpy(), expected)
+        np.testing.assert_equal(da.to_numpy(), arr)
+
+    @requires_cupy
+    def test_from_cupy(self):
+        import cupy as cp
+
+        arr = np.array([1, 2, 3])
+        da = xr.DataArray(
+            cp.array(arr), dims="x", coords={"lat": ("x", cp.array(arr + 3))}
+        )
+
+        expected = xr.DataArray(arr, dims="x", coords={"lat": ("x", arr + 3)})
+        assert_identical(da.as_numpy(), expected)
+        np.testing.assert_equal(da.to_numpy(), arr)
+
+    @requires_dask
+    @requires_pint_0_15
+    def test_from_pint_wrapping_dask(self):
+        import dask
+        from pint import Quantity
+
+        arr = np.array([1, 2, 3])
+        d = dask.array.from_array(arr)
+        da = xr.DataArray(
+            Quantity(d, units="Pa"),
+            dims="x",
+            coords={"lat": ("x", Quantity(d, units="m") * 2)},
+        )
+
+        result = da.as_numpy()
+        result.name = None  # remove dask-assigned name
+        expected = xr.DataArray(arr, dims="x", coords={"lat": ("x", arr * 2)})
+        assert_identical(result, expected)
+        np.testing.assert_equal(da.to_numpy(), arr)
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 317602c8..f6fb4aa0 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -44,9 +44,11 @@ from . import (
     has_dask,
     requires_bottleneck,
     requires_cftime,
+    requires_cupy,
     requires_dask,
     requires_numbagg,
     requires_numexpr,
+    requires_pint_0_15,
     requires_scipy,
     requires_sparse,
     source_ndarray,
@@ -6760,3 +6762,74 @@ def test_clip(ds):
 
     result = ds.clip(min=ds.mean("y"), max=ds.mean("y"))
     assert result.dims == ds.dims
+
+
+class TestNumpyCoercion:
+    def test_from_numpy(self):
+        ds = xr.Dataset({"a": ("x", [1, 2, 3])}, coords={"lat": ("x", [4, 5, 6])})
+
+        assert_identical(ds.as_numpy(), ds)
+
+    @requires_dask
+    def test_from_dask(self):
+        ds = xr.Dataset({"a": ("x", [1, 2, 3])}, coords={"lat": ("x", [4, 5, 6])})
+        ds_chunked = ds.chunk(1)
+
+        assert_identical(ds_chunked.as_numpy(), ds.compute())
+
+    @requires_pint_0_15
+    def test_from_pint(self):
+        from pint import Quantity
+
+        arr = np.array([1, 2, 3])
+        ds = xr.Dataset(
+            {"a": ("x", Quantity(arr, units="Pa"))},
+            coords={"lat": ("x", Quantity(arr + 3, units="m"))},
+        )
+
+        expected = xr.Dataset({"a": ("x", [1, 2, 3])}, coords={"lat": ("x", arr + 3)})
+        assert_identical(ds.as_numpy(), expected)
+
+    @requires_sparse
+    def test_from_sparse(self):
+        import sparse
+
+        arr = np.diagflat([1, 2, 3])
+        sparr = sparse.COO.from_numpy(arr)
+        ds = xr.Dataset(
+            {"a": (["x", "y"], sparr)}, coords={"elev": (("x", "y"), sparr + 3)}
+        )
+
+        expected = xr.Dataset(
+            {"a": (["x", "y"], arr)}, coords={"elev": (("x", "y"), arr + 3)}
+        )
+        assert_identical(ds.as_numpy(), expected)
+
+    @requires_cupy
+    def test_from_cupy(self):
+        import cupy as cp
+
+        arr = np.array([1, 2, 3])
+        ds = xr.Dataset(
+            {"a": ("x", cp.array(arr))}, coords={"lat": ("x", cp.array(arr + 3))}
+        )
+
+        expected = xr.Dataset({"a": ("x", [1, 2, 3])}, coords={"lat": ("x", arr + 3)})
+        assert_identical(ds.as_numpy(), expected)
+
+    @requires_dask
+    @requires_pint_0_15
+    def test_from_pint_wrapping_dask(self):
+        import dask
+        from pint import Quantity
+
+        arr = np.array([1, 2, 3])
+        d = dask.array.from_array(arr)
+        ds = xr.Dataset(
+            {"a": ("x", Quantity(d, units="Pa"))},
+            coords={"lat": ("x", Quantity(d, units="m") * 2)},
+        )
+
+        result = ds.as_numpy()
+        expected = xr.Dataset({"a": ("x", arr)}, coords={"lat": ("x", arr * 2)})
+        assert_identical(result, expected)
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
index 02478929..d999bc1e 100644
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -33,7 +33,9 @@ from . import (
     assert_equal,
     assert_identical,
     raise_if_dask_computes,
+    requires_cupy,
     requires_dask,
+    requires_pint_0_15,
     requires_sparse,
     source_ndarray,
 )
@@ -2554,3 +2556,68 @@ def test_clip(var):
             var.mean("z").data[:, :, np.newaxis],
         ),
     )
+
+
+@pytest.mark.parametrize("Var", [Variable, IndexVariable])
+class TestNumpyCoercion:
+    def test_from_numpy(self, Var):
+        v = Var("x", [1, 2, 3])
+
+        assert_identical(v.as_numpy(), v)
+        np.testing.assert_equal(v.to_numpy(), np.array([1, 2, 3]))
+
+    @requires_dask
+    def test_from_dask(self, Var):
+        v = Var("x", [1, 2, 3])
+        v_chunked = v.chunk(1)
+
+        assert_identical(v_chunked.as_numpy(), v.compute())
+        np.testing.assert_equal(v.to_numpy(), np.array([1, 2, 3]))
+
+    @requires_pint_0_15
+    def test_from_pint(self, Var):
+        from pint import Quantity
+
+        arr = np.array([1, 2, 3])
+        v = Var("x", Quantity(arr, units="m"))
+
+        assert_identical(v.as_numpy(), Var("x", arr))
+        np.testing.assert_equal(v.to_numpy(), arr)
+
+    @requires_sparse
+    def test_from_sparse(self, Var):
+        if Var is IndexVariable:
+            pytest.skip("Can't have 2D IndexVariables")
+
+        import sparse
+
+        arr = np.diagflat([1, 2, 3])
+        sparr = sparse.COO(coords=[[0, 1, 2], [0, 1, 2]], data=[1, 2, 3])
+        v = Variable(["x", "y"], sparr)
+
+        assert_identical(v.as_numpy(), Variable(["x", "y"], arr))
+        np.testing.assert_equal(v.to_numpy(), arr)
+
+    @requires_cupy
+    def test_from_cupy(self, Var):
+        import cupy as cp
+
+        arr = np.array([1, 2, 3])
+        v = Var("x", cp.array(arr))
+
+        assert_identical(v.as_numpy(), Var("x", arr))
+        np.testing.assert_equal(v.to_numpy(), arr)
+
+    @requires_dask
+    @requires_pint_0_15
+    def test_from_pint_wrapping_dask(self, Var):
+        import dask
+        from pint import Quantity
+
+        arr = np.array([1, 2, 3])
+        d = dask.array.from_array(np.array([1, 2, 3]))
+        v = Var("x", Quantity(d, units="m"))
+
+        result = v.as_numpy()
+        assert_identical(result, Var("x", arr))
+        np.testing.assert_equal(v.to_numpy(), arr)