Repository: euro-cordex/py-cordex

Issue:
use new transform_bounds function by default
see also #16 

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex) Report
> Merging [#102](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex) (779ae70) into [master](https://codecov.io/gh/euro-cordex/py-cordex/commit/d444a09d516f1cd1884af727198dd547b20e1ed7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex) (d444a09) will **increase** coverage by `1.79%`.
> The diff coverage is `95.91%`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex)

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://codecov.io/gh/euro-cordex/py-cordex/pull/102/graphs/tree.svg?width=650&height=150&src=pr&token=D6BAMOZZT3&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex)](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex)

```diff
@@            Coverage Diff             @@
##           master     #102      +/-   ##
==========================================
+ Coverage   77.14%   78.93%   +1.79%     
==========================================
  Files          22       22              
  Lines        1098     1111      +13     
==========================================
+ Hits          847      877      +30     
+ Misses        251      234      -17     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `78.93% <95.91%> (+1.79%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex) | Coverage Δ | |
|---|---|---|
| [cordex/domain.py](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex#diff-Y29yZGV4L2RvbWFpbi5weQ==) | `82.00% <90.47%> (+8.99%)` | :arrow_up: |
| [cordex/\_\_init\_\_.py](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex#diff-Y29yZGV4L19faW5pdF9fLnB5) | `80.00% <100.00%> (ø)` | |
| [cordex/cf.py](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex#diff-Y29yZGV4L2NmLnB5) | `100.00% <100.00%> (ø)` | |
| [cordex/transform.py](https://codecov.io/gh/euro-cordex/py-cordex/pull/102?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=euro-cordex#diff-Y29yZGV4L3RyYW5zZm9ybS5weQ==) | `97.22% <100.00%> (+0.83%)` | :arrow_up: |

</details>

Patch:
diff --git a/cordex/__init__.py b/cordex/__init__.py
index 76500e9..e7d0bc3 100644
--- a/cordex/__init__.py
+++ b/cordex/__init__.py
@@ -3,7 +3,13 @@ import pkg_resources
 from . import regions, tables, tutorial
 from .domain import cordex_domain, create_dataset, domain_info, vertices
 from .tables import domains, ecmwf
-from .transform import map_crs, rotated_coord_transform, transform, transform_coords
+from .transform import (
+    map_crs,
+    rotated_coord_transform,
+    transform,
+    transform_bounds,
+    transform_coords,
+)
 
 try:
     __version__ = pkg_resources.get_distribution("py-cordex").version
@@ -26,6 +32,7 @@ __all__ = [
     "map_crs",
     "transform",
     "transform_coords",
+    "transform_bounds",
     "domains",
     "ecmwf",
 ]
diff --git a/cordex/domain.py b/cordex/domain.py
index 0982a78..702e4d8 100644
--- a/cordex/domain.py
+++ b/cordex/domain.py
@@ -28,7 +28,7 @@ from pyproj import CRS
 from . import cf
 from .config import nround
 from .tables import domains
-from .transform import grid_mapping, transform, transform_coords
+from .transform import grid_mapping, transform, transform_bounds, transform_coords
 from .utils import get_tempfile
 
 
@@ -99,6 +99,10 @@ def cordex_domain(
     Grid : xr.Dataset
         Dataset containing a CORDEX grid.
 
+    References
+    ----------
+    Please refer to the CF conventions document : https://cfconventions.org/Data/cf-conventions/cf-conventions-1.10/cf-conventions.html#grid-mappings-and-projections
+
     Example
     -------
 
@@ -344,8 +348,8 @@ def _get_rotated_dataset(
         coord.attrs = cf.coords[key]
 
     if bounds is True:
-        v = vertices(ds.rlon, ds.rlat, CRS.from_cf(mapping.attrs))
-        ds = xr.merge([ds, v])
+        ds = transform_bounds(ds)
+        # ds = xr.merge([ds, v])
         ds[cf.LON_NAME].attrs["bounds"] = cf.LON_BOUNDS
         ds[cf.LAT_NAME].attrs["bounds"] = cf.LAT_BOUNDS
 
@@ -434,7 +438,7 @@ def bounds(coords):
     # return xr.merge([_bounds(coord).left.rename({"left": coord.name+"bounds"}) for coord in coords])
 
 
-def vertices_new(ds, src_crs, trg_crs=None):
+def vertices(rlon, rlat, src_crs, trg_crs=None):
     """Compute lon and lat vertices.
 
     Transformation of rlon vertices and rlat vertices
@@ -455,66 +459,10 @@ def vertices_new(ds, src_crs, trg_crs=None):
 
     """
     warn(
-        "Order of vertices has changed since v0.3.2 to CF Conventions, see https://github.com/euro-cordex/py-cordex/issues/34"
-    )
-    # ds = xr.merge([rlon_bounds, rlat_bounds])
-    rlon_bounds = ds.cf.add_bounds("rlon").rlon_bounds.drop(
-        "rlon_bounds"
-    )  # _bounds(ds.rlon)
-    rlat_bounds = ds.cf.add_bounds("rlat").rlat_bounds.drop(
-        "rlat_bounds"
-    )  # _bounds(ds.rlat)
-    # maps each vertex to lat lon coordinates
-    # order is counterclockwise starting from lower left vertex
-    v1 = transform(
-        rlon_bounds.isel(bounds=0), rlat_bounds.isel(bounds=0), src_crs, trg_crs
+        "vertices is deprecated, please use transform_bounds instead",
+        DeprecationWarning,
+        stacklevel=2,
     )
-    v2 = transform(
-        rlon_bounds.isel(bounds=1), rlat_bounds.isel(bounds=0), src_crs, trg_crs
-    )
-    v3 = transform(
-        rlon_bounds.isel(bounds=1), rlat_bounds.isel(bounds=1), src_crs, trg_crs
-    )
-    v4 = transform(
-        rlon_bounds.isel(bounds=0), rlat_bounds.isel(bounds=1), src_crs, trg_crs
-    )
-    lon_vertices = xr.concat(
-        [v1[0], v2[0], v3[0], v4[0]], dim=cf.BOUNDS_DIM
-    ).transpose()
-    #    ..., "vertices"
-    # )
-    lat_vertices = xr.concat(
-        [v1[1], v2[1], v3[1], v4[1]], dim=cf.BOUNDS_DIM
-    ).transpose()
-    #    ..., "vertices"
-    # )
-    lon_vertices.name = cf.LON_BOUNDS
-    lat_vertices.name = cf.LAT_BOUNDS
-    lon_vertices.attrs = cf.coords[cf.LON_BOUNDS]
-    lat_vertices.attrs = cf.coords[cf.LAT_BOUNDS]
-    return xr.merge([lat_vertices, lon_vertices])
-
-
-def vertices(rlon, rlat, src_crs, trg_crs=None):
-    """Compute lon and lat vertices.
-
-    Transformation of rlon vertices and rlat vertices
-    into the target crs according to
-    https://cfconventions.org/cf-conventions/cf-conventions.html#cell-boundaries
-
-    Parameters
-    ----------
-    rlon : xr.DataArray
-        Longitude in rotated pole grid.
-    rlat : xr.DataArray
-        Latitude in rotated pole grid.
-
-    Returns
-    -------
-    vertices : xr.Dataset
-        lon_vertices and lat_vertices in target crs.
-
-    """
     rlon_bounds = _bounds(rlon)
     rlat_bounds = _bounds(rlat)
     # maps each vertex to lat lon coordinates
diff --git a/cordex/transform.py b/cordex/transform.py
index 4c5c55d..156fcb5 100644
--- a/cordex/transform.py
+++ b/cordex/transform.py
@@ -160,6 +160,7 @@ def transform_coords(ds, src_crs=None, trg_crs=None, trg_dims=None):
         Dataset with transformed coordinates.
 
     """
+    ds = ds.copy(deep=False)
 
     if trg_crs is None:
         # default target crs
@@ -174,6 +175,101 @@ def transform_coords(ds, src_crs=None, trg_crs=None, trg_dims=None):
     return ds.assign_coords({trg_dims[0]: xt, trg_dims[1]: yt})
 
 
+def transform_bounds(ds, src_crs=None, trg_crs=None, trg_dims=None):
+    """Transform X and Y bounds of a Dataset.
+
+    Transformation of linear X and Y coordinates
+    into the target crs according to
+    https://cfconventions.org/cf-conventions/cf-conventions.html#cell-boundaries
+
+    Parameters
+    ----------
+    ds : xr.Dataset
+        Dataset containing linear X and Y coordinates, e.g., `rlon` and `rlat`
+    src_crs : pyproj.CRS
+        Source coordinate reference system in which X and Y are defined.
+        If not supplied, a `grid_mapping` variable should be available
+        to define the source CRS.
+    trg_crs : pyproj.CRS
+        Target coordinate reference system into which bounds
+        should be transformed. If not supplied, ``EPSG:4326`` is the default.
+
+    Returns
+    -------
+    bounds : xr.Dataset
+        Dataset with X and Y bounds in target crs. Probably some 2D coordinates.
+
+    References
+    ----------
+    Please refer to the CF conventions document : https://cfconventions.org/cf-conventions/cf-conventions.html#cell-boundaries
+
+    """
+    ds = ds.copy(deep=False)
+
+    if src_crs is None:
+        src_crs = CRS.from_cf(ds.cf["grid_mapping"].attrs)
+    if trg_crs is None:
+        # default target crs
+        trg_crs = CRS("EPSG:4326")
+    if trg_dims is None:
+        trg_dims = (
+            ds.cf["longitude"].name + "_vertices",
+            ds.cf["latitude"].name + "_vertices",
+        )
+    # ds = xr.merge([rlon_bounds, rlat_bounds])
+    rlon_bounds = (
+        ds.cf.add_bounds(ds.cf["X"].dims).cf.get_bounds("X").drop("rlon_bounds")
+    )
+    rlat_bounds = (
+        ds.cf.add_bounds(ds.cf["Y"].dims).cf.get_bounds("Y").drop("rlat_bounds")
+    )
+
+    # order is counterclockwise starting from lower left vertex
+    v1 = transform(
+        rlon_bounds.isel(bounds=0), rlat_bounds.isel(bounds=0), src_crs, trg_crs
+    )
+    v2 = transform(
+        rlon_bounds.isel(bounds=1), rlat_bounds.isel(bounds=0), src_crs, trg_crs
+    )
+    v3 = transform(
+        rlon_bounds.isel(bounds=1), rlat_bounds.isel(bounds=1), src_crs, trg_crs
+    )
+    v4 = transform(
+        rlon_bounds.isel(bounds=0), rlat_bounds.isel(bounds=1), src_crs, trg_crs
+    )
+    lon_vertices = xr.concat(
+        [v1[0], v2[0], v3[0], v4[0]], dim=cf.BOUNDS_DIM
+    ).transpose()
+    #    ..., "vertices"
+    # )
+    lat_vertices = xr.concat(
+        [v1[1], v2[1], v3[1], v4[1]], dim=cf.BOUNDS_DIM
+    ).transpose()
+
+    lon_vertices.name = cf.LON_BOUNDS
+    lat_vertices.name = cf.LAT_BOUNDS
+    lon_vertices.attrs = cf.coords[cf.LON_BOUNDS]
+    lat_vertices.attrs = cf.coords[cf.LAT_BOUNDS]
+
+    bounds = xr.merge([lon_vertices, lat_vertices]).transpose(
+        ds.cf["Y"].dims[0], ds.cf["X"].dims[0], cf.BOUNDS_DIM
+    )
+
+    ds[cf.LON_NAME].attrs["bounds"] = cf.LON_BOUNDS
+    ds[cf.LAT_NAME].attrs["bounds"] = cf.LAT_BOUNDS
+
+    return ds.assign_coords(
+        {
+            trg_dims[0]: bounds.lon_vertices.drop_vars(
+                (ds.cf["X"].name, ds.cf["Y"].name)
+            ),
+            trg_dims[1]: bounds.lat_vertices.drop_vars(
+                (ds.cf["X"].name, ds.cf["Y"].name)
+            ),
+        }
+    )
+
+
 def rotated_coord_transform(lon, lat, np_lon, np_lat, direction="rot2geo"):
     """Transforms a coordinate into a rotated grid coordinate and vice versa.
 
diff --git a/docs/api.rst b/docs/api.rst
index f5a3a5a..3890c99 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -16,9 +16,9 @@ Top-level functions
    cordex_domain
    domain_info
    create_dataset
-   vertices
    transform
    transform_coords
+   transform_bounds
 
 Tutorial
 ========
diff --git a/docs/whats_new.rst b/docs/whats_new.rst
index bfef787..01255c5 100644
--- a/docs/whats_new.rst
+++ b/docs/whats_new.rst
@@ -16,12 +16,14 @@ New Features
 
 - Cmorization module from ``pyremo.cmor`` has been moved upstream into ``cordex.cmor``. A new cmorization funcion :py:meth:`cmor.cmorize_variable` now allows for easy cmorization using the archive specifications included in ``py-cordex`` (:pull:`68`, :pull:`74`, :pull:`76`, :pull:`77`, :pull:`78`, :pull:`92`).
 
-- Added :py:meth:`transform` and :py:meth:`transform_coords` in favour of deprecated :py:meth:`map_crs` and :py:meth:`rotated_coord_transform`. Dropped ``cartopy`` dependency in favour of ``pyproj`` (:pull:`71`).
+- Added :py:meth:`transform`, :py:meth:`transform_coords` and :py:meth:`transform_bounds` in favour of deprecated :py:meth:`map_crs`, :py:meth:`rotated_coord_transform` and :py:meth:`vertices`. Dropped ``cartopy`` dependency in favour of ``pyproj`` (:pull:`71`, :pull:`102`).
 
 Deprecations
 ~~~~~~~~~~~~
 
-- ``add_vertices`` keyword is deprecated in favour of ``bounds`` keyword in :py:meth:`cordex_domain` and :py:meth:`create_dataset` (:pull:`101`).
+- ``add_vertices`` keyword is deprecated in favour of ``bounds`` keyword in :py:meth:`cordex_domain` and :py:meth:`create_dataset`. Note that the 2D longitude and latitude bounds (``lon_vertices``, ``lat_vertices``) will now appear as coordinates in the domain dataset by default and not as data variables (:pull:`101`).
+- :py:meth:`map_crs` and :py:meth:`rotated_coord_transform` are deprecated and will be removed in the future (:pull:`71`).
+- :py:meth:`vertices` is deprecated, please use :py:meth:`transform_bounds` instead (:pull:`102`).
 
 Internal Changes
 ~~~~~~~~~~~~~~~~


Test patch:
diff --git a/tests/test_domain.py b/tests/test_domain.py
index d4abc54..09ba43a 100644
--- a/tests/test_domain.py
+++ b/tests/test_domain.py
@@ -104,5 +104,4 @@ def test_vertices():
         eur11.rotated_latitude_longitude.grid_north_pole_longitude,
         eur11.rotated_latitude_longitude.grid_north_pole_latitude,
     )
-    vertices = cx.vertices(eur11.rlon, eur11.rlat, src_crs=ccrs.RotatedPole(*pole))
-    return vertices
+    cx.vertices(eur11.rlon, eur11.rlat, src_crs=ccrs.RotatedPole(*pole))
diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py
index d60a601..c505299 100644
--- a/tests/test_preprocessing.py
+++ b/tests/test_preprocessing.py
@@ -22,10 +22,10 @@ from . import requires_xesmf
 
 
 def create_test_ds(
-    name, pol_name="rotated_latitude_longitude", dummy=True, add_vertices=True, **kwargs
+    name, pol_name="rotated_latitude_longitude", dummy=True, bounds=True, **kwargs
 ):
     domain = cordex_domain(
-        name, mapping_name=pol_name, dummy=dummy, add_vertices=add_vertices, **kwargs
+        name, mapping_name=pol_name, dummy=dummy, bounds=bounds, **kwargs
     )
     return domain
 
@@ -65,8 +65,10 @@ def create_wrf_test(name):
 
 def test_wrf_case():
     """Test the wrf exception"""
-    ds = create_wrf_test("EUR-11")
-    assert rename_cordex(ds).equals(create_test_ds("EUR-11"))
+    ds = xr.decode_cf(rename_cordex(create_wrf_test("EUR-11")), decode_coords="all")
+    xr.testing.assert_equal(
+        ds, xr.decode_cf(create_test_ds("EUR-11"), decode_coords="all")
+    )
 
 
 @pytest.mark.parametrize("lon_name", ["longitude"])
@@ -84,7 +86,11 @@ def test_rename_cordex(lon_name, lat_name, pol_name, lon_vertices, lat_vertices)
             "lat_vertices": lat_vertices,
         }
     )
-    assert rename_cordex(dm).equals(create_test_ds("EUR-11"))
+    # assert rename_cordex(dm).equals(create_test_ds("EUR-11"))
+    xr.testing.assert_equal(
+        xr.decode_cf(rename_cordex(dm), decode_coords="all"),
+        xr.decode_cf(create_test_ds("EUR-11"), decode_coords="all"),
+    )
 
 
 def test_grid_mapping():
diff --git a/tests/test_transform.py b/tests/test_transform.py
index 9b0a34f..5ab6b3d 100644
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -55,3 +55,13 @@ def test_transform():
 
     assert np.allclose(ds1.lon, ds1.xt)
     assert np.allclose(ds1.lat, ds1.yt)
+
+
+@requires_cartopy
+def test_bounds():
+    # assert that we get the same bounds as before
+    ds = cx.cordex_domain("EUR-11", bounds=True)
+    _v = cx.vertices(ds.rlon, ds.rlat, CRS.from_cf(ds.cf["grid_mapping"].attrs))
+    v = cx.transform_bounds(ds)
+    np.array_equal(v.lon_vertices, _v.lon_vertices)
+    np.array_equal(v.lat_vertices, _v.lat_vertices)