Repository: geopandas/geopandas
Pull request #2539 (2022-08-27 14:56:12)

ENH: Added make_valid method from shapely
Part of #2010

jorisvandenbossche: @SimoParmeg you have some conflicts here (now I merged the other PR). I think the easiest way to resolve those conflicts will be to fetch the latest upstream and merge that in your branch here, typically something like:

```
git fetch upstream
git merge upstream/main
... # fix conflicts, "git add" files that were updated
git commit
``` 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1af58cc..3e6b63f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ for more details.
 New features and improvements:
 
 - Added ``normalize()`` method from shapely to GeoSeries/GeoDataframe (#2537)
+- Added ``make_valid()`` method from shapely to GeoSeries/GeoDataframe (#2539)
 - Add ``where`` filter to ``read_file`` (#2552)
 
 Deprecations and compatibility notes:
@@ -28,7 +29,8 @@ Deprecations and compatibility notes:
 Bug fixes:
 - Accessing `gdf.geometry` where the active geometry column is missing, and a column named `"geometry"` is present 
   will now raise an `AttributeError`, rather than returning `gdf["geometry"]` (#2575) 
-
+- Combining GeoSeries/GeoDataFrames with ``pandas.concat`` will no longer silently
+  override CRS information if not all inputs have the same CRS (#2056).
 
 Version 0.11.1 (July 24, 2022)
 ------------------------------
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index e5736f2a..45d383dd 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -15,6 +15,7 @@ import shapely.geos
 import shapely.ops
 import shapely.wkb
 import shapely.wkt
+import shapely.validation
 
 from shapely.geometry.base import BaseGeometry
 
@@ -975,6 +976,26 @@ def normalize(data):
     return out
 
 
+def make_valid(data):
+    if compat.USE_SHAPELY_20:
+        return shapely.make_valid(data)
+    elif compat.USE_PYGEOS:
+        return pygeos.make_valid(data)
+    elif not compat.SHAPELY_GE_18:
+        raise NotImplementedError(
+            f"shapely >= 1.8 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+    else:
+        out = np.empty(len(data), dtype=object)
+        with compat.ignore_shapely2_warnings():
+            out[:] = [
+                shapely.validation.make_valid(geom) if geom is not None else None
+                for geom in data
+            ]
+    return out
+
+
 def project(data, other, normalized=False):
     if compat.USE_SHAPELY_20:
         return shapely.line_locate_point(data, other, normalized=normalized)
diff --git a/geopandas/array.py b/geopandas/array.py
index c2065565..779e7291 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -514,6 +514,9 @@ class GeometryArray(ExtensionArray):
     def normalize(self):
         return GeometryArray(vectorized.normalize(self.data), crs=self.crs)
 
+    def make_valid(self):
+        return GeometryArray(vectorized.make_valid(self.data), crs=self.crs)
+
     #
     # Binary predicates
     #
@@ -1317,7 +1320,7 @@ class GeometryArray(ExtensionArray):
         ExtensionArray
         """
         data = np.concatenate([ga.data for ga in to_concat])
-        return GeometryArray(data, crs=to_concat[0].crs)
+        return GeometryArray(data, crs=_get_common_crs(to_concat))
 
     def _reduce(self, name, skipna=True, **kwargs):
         # including the base class version here (that raises by default)
@@ -1388,3 +1391,26 @@ class GeometryArray(ExtensionArray):
             else:
                 return False
         return (self == item).any()
+
+
+def _get_common_crs(arr_seq):
+
+    crs_set = {arr.crs for arr in arr_seq}
+    crs_not_none = [crs for crs in crs_set if crs is not None]
+    names = [crs.name for crs in crs_not_none]
+
+    if len(crs_not_none) == 0:
+        return None
+    if len(crs_not_none) == 1:
+        if len(crs_set) != 1:
+            warnings.warn(
+                "CRS not set for some of the concatenation inputs. "
+                f"Setting output's CRS as {names[0]} "
+                "(the single non-null crs provided)."
+            )
+        return crs_not_none[0]
+
+    raise ValueError(
+        f"Cannot determine common CRS for concatenation inputs, got {names}. "
+        "Use `to_crs()` to transform geometries to the same CRS before merging."
+    )
diff --git a/geopandas/base.py b/geopandas/base.py
index 437a6119..8302b324 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -725,6 +725,45 @@ GeometryCollection
         """
         return _delegate_geo_method("normalize", self)
 
+    def make_valid(self):
+        """
+        Repairs invalid geometries.
+
+        Returns a ``GeoSeries`` with valid geometries.
+        If the input geometry is already valid, then it will be preserved.
+        In many cases, in order to create a valid geometry, the input
+        geometry must be split into multiple parts or multiple geometries.
+        If the geometry must be split into multiple parts of the same type
+        to be made valid, then a multi-part geometry will be returned
+        (e.g. a MultiPolygon).
+        If the geometry must be split into multiple parts of different types
+        to be made valid, then a GeometryCollection will be returned.
+
+        Examples
+        --------
+        >>> from shapely.geometry import MultiPolygon, Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (0, 2), (1, 1), (2, 2), (2, 0), (1, 1), (0, 0)]),
+        ...         Polygon([(0, 2), (0, 1), (2, 0), (0, 0), (0, 2)]),
+        ...         LineString([(0, 0), (1, 1), (1, 0)]),
+        ...     ],
+        ...     crs='EPSG:3857',
+        ... )
+        >>> s
+        0    POLYGON ((0.000 0.000, 0.000 2.000, 1.000 1.00...
+        1    POLYGON ((0.000 2.000, 0.000 1.000, 2.000 0.00...
+        2    LINESTRING (0.000 0.000, 1.000 1.000, 1.000 0....
+        dtype: geometry
+
+        >>> s.make_valid()
+        0    MULTIPOLYGON (((1.000 1.000, 0.000 0.000, 0.00...
+        1    GEOMETRYCOLLECTION (POLYGON ((2.000 0.000, 0.0...
+        2    LINESTRING (0.000 0.000, 1.000 1.000, 1.000 0....
+        dtype: geometry
+        """
+        return _delegate_geo_method("make_valid", self)
+
     #
     # Reduction operations that return a Shapely geometry
     #


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index d29932dc..606102e3 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -5,7 +5,16 @@ import numpy as np
 from numpy.testing import assert_array_equal
 from pandas import DataFrame, Index, MultiIndex, Series
 
-from shapely.geometry import LinearRing, LineString, MultiPoint, Point, Polygon
+import shapely
+
+from shapely.geometry import (
+    LinearRing,
+    LineString,
+    MultiPoint,
+    Point,
+    Polygon,
+    MultiPolygon,
+)
 from shapely.geometry.collection import GeometryCollection
 from shapely.ops import unary_union
 from shapely import wkt
@@ -682,6 +691,43 @@ class TestGeomMethods:
         expected = GeoSeries([polygon2, linestring, point])
         assert_geoseries_equal(series.normalize(), expected)
 
+    @pytest.mark.skipif(
+        not compat.SHAPELY_GE_18,
+        reason="make_valid keyword introduced in shapely 1.8.0",
+    )
+    def test_make_valid(self):
+        polygon1 = Polygon([(0, 0), (0, 2), (1, 1), (2, 2), (2, 0), (1, 1), (0, 0)])
+        polygon2 = Polygon([(0, 2), (0, 1), (2, 0), (0, 0), (0, 2)])
+        linestring = LineString([(0, 0), (1, 1), (1, 0)])
+        series = GeoSeries([polygon1, polygon2, linestring])
+        out_polygon1 = MultiPolygon(
+            [
+                Polygon([(1, 1), (0, 0), (0, 2), (1, 1)]),
+                Polygon([(2, 0), (1, 1), (2, 2), (2, 0)]),
+            ]
+        )
+        out_polygon2 = GeometryCollection(
+            [Polygon([(2, 0), (0, 0), (0, 1), (2, 0)]), LineString([(0, 2), (0, 1)])]
+        )
+        expected = GeoSeries([out_polygon1, out_polygon2, linestring])
+        assert not series.is_valid.all()
+        result = series.make_valid()
+        assert_geoseries_equal(result, expected)
+        assert result.is_valid.all()
+
+    @pytest.mark.skipif(
+        compat.SHAPELY_GE_18,
+        reason="make_valid keyword introduced in shapely 1.8.0",
+    )
+    def test_make_valid_shapely_pre18(self):
+        s = GeoSeries([Point(1, 1)])
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 1.8 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            s.make_valid()
+
     def test_convex_hull(self):
         # the convex hull of a square should be the same as the square
         squares = GeoSeries([self.sq for i in range(3)])
diff --git a/geopandas/tests/test_merge.py b/geopandas/tests/test_merge.py
index 5794b6ca..3f65346a 100644
--- a/geopandas/tests/test_merge.py
+++ b/geopandas/tests/test_merge.py
@@ -54,11 +54,6 @@ class TestMerging:
         self._check_metadata(res)
         exp = GeoDataFrame(pd.concat([pd.DataFrame(self.gdf), pd.DataFrame(self.gdf)]))
         assert_geodataframe_equal(exp, res)
-        # check metadata comes from first gdf
-        res4 = pd.concat([self.gdf.set_crs("epsg:4326"), self.gdf], axis=0)
-        # Note: this behaviour potentially does not make sense. If geom cols are
-        # concatenated but have different CRS, then the CRS will be overridden.
-        self._check_metadata(res4, crs="epsg:4326")
 
         # series
         res = pd.concat([self.gdf.geometry, self.gdf.geometry])
@@ -66,6 +61,48 @@ class TestMerging:
         assert isinstance(res, GeoSeries)
         assert isinstance(res.geometry, GeoSeries)
 
+    def test_concat_axis0_crs(self):
+
+        # CRS not set for both GeoDataFrame
+        res = pd.concat([self.gdf, self.gdf])
+        self._check_metadata(res)
+
+        # CRS set for both GeoDataFrame, same CRS
+        res1 = pd.concat([self.gdf.set_crs("epsg:4326"), self.gdf.set_crs("epsg:4326")])
+        self._check_metadata(res1, crs="epsg:4326")
+
+        # CRS not set for one GeoDataFrame, but set for the other GeoDataFrame
+        with pytest.warns(
+            UserWarning, match=r"CRS not set for some of the concatenation inputs.*"
+        ):
+            res2 = pd.concat([self.gdf, self.gdf.set_crs("epsg:4326")])
+            self._check_metadata(res2, crs="epsg:4326")
+
+        # CRS set for both GeoDataFrame, different CRS
+        with pytest.raises(
+            ValueError, match=r"Cannot determine common CRS for concatenation inputs.*"
+        ):
+            pd.concat([self.gdf.set_crs("epsg:4326"), self.gdf.set_crs("epsg:4327")])
+
+        # CRS not set for one GeoDataFrame, but set for the other GeoDataFrames,
+        # same CRS
+        with pytest.warns(
+            UserWarning, match=r"CRS not set for some of the concatenation inputs.*"
+        ):
+            res3 = pd.concat(
+                [self.gdf, self.gdf.set_crs("epsg:4326"), self.gdf.set_crs("epsg:4326")]
+            )
+            self._check_metadata(res3, crs="epsg:4326")
+
+        # CRS not set for one GeoDataFrame, but set for the other GeoDataFrames,
+        # different CRS
+        with pytest.raises(
+            ValueError, match=r"Cannot determine common CRS for concatenation inputs.*"
+        ):
+            pd.concat(
+                [self.gdf, self.gdf.set_crs("epsg:4326"), self.gdf.set_crs("epsg:4327")]
+            )
+
     def test_concat_axis1(self):
 
         res = pd.concat([self.gdf, self.df], axis=1)