Repository: regionmask/regionmask

Issue:
create to_* methods
It could be usefull to get a geo-dataframe for some applications (plot chloropleths)...

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/regionmask/regionmask/pull/298?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) Report
> Merging [#298](https://codecov.io/gh/regionmask/regionmask/pull/298?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) (7634794) into [master](https://codecov.io/gh/regionmask/regionmask/commit/222f00bcff7a65b01f9e5ba4ee79a70dc6174cd8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) (222f00b) will **decrease** coverage by `1.59%`.
> The diff coverage is `42.85%`.

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

```diff
@@            Coverage Diff             @@
##           master     #298      +/-   ##
==========================================
- Coverage   98.56%   96.97%   -1.60%     
==========================================
  Files          15       15              
  Lines        1255     1288      +33     
==========================================
+ Hits         1237     1249      +12     
- Misses         18       39      +21     
```

| Flag | Coverage Δ | |
|---|---|---|
| bare-minimum | `90.18% <42.85%> (-1.38%)` | :arrow_down: |
| min-all-deps | `?` | |
| unittests | `96.97% <42.85%> (-1.60%)` | :arrow_down: |

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=regionmask#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/regionmask/regionmask/pull/298?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) | Coverage Δ | |
|---|---|---|
| [regionmask/core/formatting.py](https://codecov.io/gh/regionmask/regionmask/pull/298/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9jb3JlL2Zvcm1hdHRpbmcucHk=) | `96.96% <ø> (-0.09%)` | :arrow_down: |
| [regionmask/core/regions.py](https://codecov.io/gh/regionmask/regionmask/pull/298/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9jb3JlL3JlZ2lvbnMucHk=) | `87.89% <34.48%> (-9.63%)` | :arrow_down: |
| [regionmask/core/\_geopandas.py](https://codecov.io/gh/regionmask/regionmask/pull/298/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9jb3JlL19nZW9wYW5kYXMucHk=) | `96.15% <83.33%> (-1.11%)` | :arrow_down: |
| [regionmask/defined\_regions/\_natural\_earth.py](https://codecov.io/gh/regionmask/regionmask/pull/298/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9kZWZpbmVkX3JlZ2lvbnMvX25hdHVyYWxfZWFydGgucHk=) | `96.37% <0.00%> (-0.52%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/regionmask/regionmask/pull/298?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/regionmask/regionmask/pull/298?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask). Last update [222f00b...7634794](https://codecov.io/gh/regionmask/regionmask/pull/298?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask).


Patch:
diff --git a/docs/api.rst b/docs/api.rst
index 18b83e6..3e483e4 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -1,6 +1,5 @@
 .. currentmodule:: regionmask
 
-#############
 API reference
 #############
 
@@ -66,6 +65,17 @@ Creating a Mask
    Regions.mask
    Regions.mask_3D
 
+Conversion
+----------
+
+.. autosummary::
+   :toctree: generated/
+
+   Regions.to_dataframe
+   Regions.to_geodataframe
+   Regions.to_geoseries
+   Regions.from_geodataframe
+
 
 Attributes
 ----------
@@ -85,7 +95,6 @@ Attributes
    Regions.lon_180
    Regions.lon_360
 
-
 _OneRegion
 ==========
 
diff --git a/docs/whats_new.rst b/docs/whats_new.rst
index 85b620f..9bdae73 100644
--- a/docs/whats_new.rst
+++ b/docs/whats_new.rst
@@ -63,6 +63,11 @@ Enhancements
   Note that only xarray arrays can be detected as unstructured grids.
   (:issue:`278`, :pull:`280`). By `Aaron Spring <https://github.com/aaronspring>`_.
 
+- Add methods to convert :py:class:`Regions` to (geo)pandas objects, namely :py:meth:`Regions.to_geodataframe`,
+  :py:meth:`Regions.to_geoseries`, :py:meth:`Regions.to_dataframe`). The geopandas.GeoDataFrame
+  can be converted back (round-tripped) using py:meth:`Regions.from_geodataframe`
+  (:issue:`50`, :pull:`298`).
+
 - The plotting methods (:py:meth:`Regions.plot` and :py:meth:`Regions.plot_regions`) now
   use a more sophisticated logic to subsample lines on GeoAxes plots. The new method is
   based on the euclidean distance of each segment. Per default the maximum distance of
diff --git a/regionmask/core/_geopandas.py b/regionmask/core/_geopandas.py
index 7f5cb74..faad752 100644
--- a/regionmask/core/_geopandas.py
+++ b/regionmask/core/_geopandas.py
@@ -1,3 +1,5 @@
+import warnings
+
 import numpy as np
 
 from ..defined_regions._natural_earth import _maybe_get_column
@@ -66,27 +68,33 @@ def from_geopandas(
     overlap=False,
 ):
     """
-    Create ``regionmask.Region`` from ``geopandas.geodataframe.GeoDataFrame``.
+    Create ``regionmask.Regions`` from a ``geopandas.GeoDataFrame``.
 
     Parameters
     ----------
-    geodataframe : geopandas.geodataframe.GeoDataFrame
+    geodataframe : geopandas.GeoDataFrame
         GeoDataFrame to be transformed to a Regions class.
+
     numbers : str, optional
         Name of the column in geodataframe that gives each region its number.
         This column must not have duplicates. If None (default), takes
         ``geodataframe.index.values``.
+
     names : str, optional
         Name of the column in shapefile that names a region. Breaks for duplicates.
         If None (default) uses "Region0", .., "RegionN".
+
     abbrevs : str, optional
         Name of the column in shapefile that five a region its abbreviation.
         Breaks for duplicates. If ``_from_name``, a combination of the first letters
         of region name is taken. If None (default) uses "r0", .., "rN".
+
     name : str, optional
         name of the ``regionmask.Region`` instance created
+
     source : str, optional
         source of the shapefile
+
     overlap : bool, default: False
         Indicates if (some of) the regions overlap. If True ``mask_3D`` will ensure
         overlapping regions are correctly assigned to grid points while ``mask`` will
@@ -102,11 +110,44 @@ def from_geopandas(
     -------
     regionmask.core.regions.Regions
 
+    See Also
+    --------
+    Regions.to_dataframe, Regions.to_geodataframe, Regions.to_geoseries, Regions.from_geodataframe
     """
+
     from geopandas import GeoDataFrame
 
     if not isinstance(geodataframe, (GeoDataFrame)):
-        raise TypeError("`geodataframe` must be a geopandas 'GeoDataFrame'")
+        raise TypeError(
+            "`geodataframe` must be a geopandas 'GeoDataFrame',"
+            f" found {type(geodataframe)}"
+        )
+
+    if any(x in geodataframe.attrs for x in ["name", "source", "overlap"]):
+        warnings.warn(
+            "Use ``regionmask.Regions.from_geodataframe`` to round-trip ``Regions``"
+        )
+
+    return _from_geopandas(
+        geodataframe,
+        numbers=numbers,
+        names=names,
+        abbrevs=abbrevs,
+        name=name,
+        source=source,
+        overlap=overlap,
+    )
+
+
+def _from_geopandas(
+    geodataframe,
+    numbers=None,
+    names=None,
+    abbrevs=None,
+    name="unnamed",
+    source=None,
+    overlap=False,
+):
 
     if numbers is not None:
         # sort, otherwise breaks
diff --git a/regionmask/core/formatting.py b/regionmask/core/formatting.py
index a3e8e80..f14c314 100644
--- a/regionmask/core/formatting.py
+++ b/regionmask/core/formatting.py
@@ -1,6 +1,6 @@
 """String formatting routines for __repr__."""
 
-import geopandas as gp
+
 from pandas.io.formats import console
 
 
@@ -48,15 +48,8 @@ def _display_metadata(name, source, overlap, max_width=80):
 def _display_regions_gp(self, max_rows, max_width, max_colwidth):  # pragma: no cover
     summary = ["Regions:"]
 
-    data = dict(
-        numbers=self.numbers,
-        abbrevs=self.abbrevs,
-        names=self.names,
-        # __repr__ of polygons can be slow
-        # geometry=self.polygons,
-    )
-
-    df = gp.GeoDataFrame.from_dict(data)
+    # __repr__ of polygons can be slow -> use pd.DataFrame
+    df = self.to_dataframe().reset_index()
 
     summary.append(
         df.to_string(
diff --git a/regionmask/core/regions.py b/regionmask/core/regions.py
index 8e43c0f..0280330 100644
--- a/regionmask/core/regions.py
+++ b/regionmask/core/regions.py
@@ -6,7 +6,9 @@
 import copy
 from collections import OrderedDict
 
+import geopandas as gp
 import numpy as np
+import pandas as pd
 from shapely.geometry import MultiPolygon, Polygon
 
 from .formatting import _display
@@ -350,6 +352,125 @@ class Regions:
 
     mask_3D.__doc__ = _inject_mask_docstring(is_3D=True, gp_method=False)
 
+    def to_dataframe(self):
+        """Convert this region into a pandas.DataFrame, excluding polygons.
+
+        See Also
+        --------
+        from_geopandas, Regions.to_geodataframe, Regions.to_geoseries, Regions.from_geodataframe
+
+        """
+
+        data = dict(
+            numbers=self.numbers,
+            abbrevs=self.abbrevs,
+            names=self.names,
+        )
+
+        df = pd.DataFrame.from_dict(data).set_index("numbers")
+        return df
+
+    def to_geodataframe(self):
+        """Convert this region into a geopandas.GeoDataFrame.
+
+        Use ``Regions.from_geodataframe`` to round-trip a geodataframe created with
+        this method.
+
+        See Also
+        --------
+        from_geopandas, Regions.to_dataframe, Regions.to_geoseries, Regions.from_geodataframe
+
+        """
+
+        data = dict(
+            numbers=self.numbers,
+            abbrevs=self.abbrevs,
+            names=self.names,
+            geometry=self.polygons,
+        )
+
+        df = gp.GeoDataFrame.from_dict(data).set_index("numbers")
+        df.attrs["name"] = self.name
+        df.attrs["source"] = self.source
+        df.attrs["overlap"] = self.overlap
+
+        return df
+
+    def to_geoseries(self):
+        """Convert this region into a geopandas.GeoSeries.
+
+        See Also
+        --------
+        from_geopandas, Regions.to_dataframe, Regions.to_geodataframe, Regions.from_geodataframe
+
+        """
+
+        df = gp.GeoSeries(self.polygons, index=self.numbers)
+        df.attrs["name"] = self.name
+        df.attrs["source"] = self.source
+        df.attrs["overlap"] = self.overlap
+
+        return df
+
+    @classmethod
+    def from_geodataframe(cls, df, *, name=None, source=None, overlap=None):
+        """
+        Convert a  ``geopandas.GeoDataFrame`` created with ``to_geodataframe`` back to
+        ``regionmask.Region`` (round trip)
+
+        Parameters
+        ----------
+        df : geopandas.GeoDataFrame
+            GeoDataFrame to be transformed to a Regions class.
+
+        name : str, optional
+            name of the Regions. If None uses ``df.attrs.get("name", "unnamed")``.
+
+        source : str, optional
+            Source of the shapefile.  If None uses ``df.attrs.get("source")``.
+
+        overlap : bool, default: None
+            Indicates if (some of) the regions overlap. If None uses
+            ``df.attrs.get("overlap")``. If True ``mask_3D`` will ensure
+            overlapping regions are correctly assigned to grid points while ``mask``
+            will error (because overlapping regions cannot be represented by a 2D mask).
+
+            If False assumes non-overlapping regions. Grid points will silently be
+            assigned to the region with the higher number (this may change in a future
+            version).
+
+            There is (currently) no automatic detection of overlapping regions.
+
+        Returns
+        -------
+        regionmask.core.regions.Regions
+
+        See Also
+        --------
+        from_geopandas, Regions.to_dataframe, Regions.to_geodataframe, Regions.to_geoseries
+        """
+
+        from regionmask.core._geopandas import _from_geopandas
+
+        if name is None:
+            name = df.attrs.get("name", "unnamed")
+
+        if source is None:
+            source = df.attrs.get("source")
+
+        if overlap is None:
+            overlap = df.attrs.get("overlap", False)
+
+        return _from_geopandas(
+            df,
+            numbers=None,
+            names="names",
+            abbrevs="abbrevs",
+            name=name,
+            source=source,
+            overlap=overlap,
+        )
+
 
 # add the plotting methods
 Regions.plot = _plot


Test patch:
diff --git a/regionmask/tests/test_Regions.py b/regionmask/tests/test_Regions.py
index badd8e6..2a47b37 100644
--- a/regionmask/tests/test_Regions.py
+++ b/regionmask/tests/test_Regions.py
@@ -1,4 +1,7 @@
+import geopandas
+import geopandas.testing
 import numpy as np
+import pandas as pd
 import pytest
 from shapely.geometry import MultiPolygon, Polygon
 
@@ -314,3 +317,106 @@ def test_overlap_getitem(overlap):
     r_select = r[[0, 2]]
 
     assert r_select.overlap is overlap
+
+
+def _check_dataframe(df, r):
+
+    assert (df.index == r.numbers).all()
+    assert (df.abbrevs == r.abbrevs).all()
+    assert (df.names == r.names).all()
+
+
+def _check_attrs(df, r):
+
+    assert df.attrs["name"] == r.name
+    assert df.attrs["source"] == r.source
+    assert df.attrs["overlap"] == r.overlap
+
+
+def _check_polygons(df, r):
+
+    from geopandas import GeoSeries
+
+    geopandas.testing.assert_geoseries_equal(df, GeoSeries(r.polygons, index=r.numbers))
+
+
+def test_to_geodataframe():
+
+    r = test_regions1
+
+    df = r.to_geodataframe()
+
+    assert isinstance(df, geopandas.GeoDataFrame)
+    _check_dataframe(df, r)
+    _check_attrs(df, r)
+    _check_polygons(df["geometry"], r)
+
+
+def test_to_series():
+
+    r = test_regions1
+
+    df = r.to_geoseries()
+
+    assert isinstance(df, geopandas.GeoSeries)
+    _check_attrs(df, r)
+    _check_polygons(df, r)
+
+
+def test_to_dataframe():
+
+    r = test_regions1
+
+    df = r.to_dataframe()
+
+    assert isinstance(df, pd.DataFrame)
+    _check_dataframe(df, r)
+
+
+def test_from_geodataframe():
+
+    import geopandas
+
+    data = dict(names=names, abbrevs=abbrevs)
+
+    df = geopandas.GeoDataFrame(data=data, geometry=[poly1, poly2], index=numbers2)
+    df.attrs = dict(source="source", name=name, overlap=True)
+
+    r = Regions.from_geodataframe(df)
+
+    _check_dataframe(df, r)
+    _check_attrs(df, r)
+    _check_polygons(df["geometry"], r)
+
+    r = Regions.from_geodataframe(df, name="test")
+    assert r.name == "test"
+
+    r = Regions.from_geodataframe(df, source="test")
+    assert r.source == "test"
+
+    r = Regions.from_geodataframe(df, overlap=False)
+    assert r.overlap is False
+
+    df.attrs = {}
+
+    r = Regions.from_geodataframe(df)
+    assert r.name == "unnamed"
+    assert r.source is None
+    assert r.overlap is False
+
+
+def test_from_geodataframe_roundtrip():
+
+    import geopandas
+
+    data = dict(abbrevs=abbrevs, names=names)
+
+    df = geopandas.GeoDataFrame(data=data, geometry=[poly1, poly2], index=numbers2)
+    df.index.name = "numbers"
+    df.attrs = dict(source="source", name=name, overlap=True)
+
+    r = Regions.from_geodataframe(df)
+
+    df_roundtrip = r.to_geodataframe()
+
+    geopandas.testing.assert_geodataframe_equal(df, df_roundtrip)
diff --git a/regionmask/tests/test_geopandas.py b/regionmask/tests/test_geopandas.py
index a63a997..93a6658 100644
--- a/regionmask/tests/test_geopandas.py
+++ b/regionmask/tests/test_geopandas.py
@@ -106,6 +106,18 @@ def test_from_geopandas_use_columns(geodataframe_clean):
     assert result.source == "source"
 
 
+@pytest.mark.parametrize("attr", ["name", "source", "overlap"])
+def test_from_geopandas_not_roundtrip_warning(attr, geodataframe_clean):
+
+    geodataframe_clean.attrs = {attr: "x"}
+
+    with pytest.warns(
+        UserWarning,
+        match="Use ``regionmask.Regions.from_geodataframe`` to round-trip ``Regions``",
+    ):
+        from_geopandas(geodataframe_clean)
+
+
 def test_from_geopandas_default(geodataframe_clean):
 
     result = from_geopandas(geodataframe_clean)