Repository: geopandas/geopandas
Pull request #3548 (2025-04-14 20:48:15)

ENH: add method and keep_collapsed arguments to make_valid
shapely 2.1 introduced the `method` and `keep_collapsed` argumetns for `make_valid`... Add them in geopandas as well.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31ce4f8b..6a9db5f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,10 +13,11 @@ New features and improvements:
 - Avoid change of the plot aspect when plotting missing values (#3438).
 - GeoDataFrame no longer hard-codes the class internally, allowing easier subclassing (#3505).
 - Improve performance of `overlay` with `how=identity` (#3504).
-- Fix ambiguous error when GeoDataFrame is initialised with a column called "crs" (#3502)
+- Fix ambiguous error when GeoDataFrame is initialised with a column called "crs" (#3502).
 - Added `disjoint_subset` union algorithm for `union_all` and `dissolve` (#3534).
 - Added `to_pandas_kwargs` argument to `from_arrow`, `read_parquet` and `read_feather`
   to allow better control of conversion of non-geometric Arrow data to DataFrames (#3466).
+- Added ``method`` and ``keep_collapsed`` argument to ``make_valid`` (#3548).
 
 Bug fixes:
 
diff --git a/environment-dev.yml b/environment-dev.yml
index 161ab862..e4b52953 100644
--- a/environment-dev.yml
+++ b/environment-dev.yml
@@ -22,7 +22,7 @@ dependencies:
 
     # styling
     - pre-commit
-    - ruff==0.5.5
+    - ruff==0.11.4
 
     # optional
     - fiona
diff --git a/geopandas/array.py b/geopandas/array.py
index 51c4e7d5..e6aa474c 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -218,6 +218,9 @@ def from_wkb(data, crs=None, on_invalid="raise"):
         - warn: a warning will be raised and invalid WKB geometries will be returned as
           None.
         - ignore: invalid WKB geometries will be returned as None without a warning.
+        - fix: an effort is made to fix invalid input geometries (e.g. close
+          unclosed rings). If this is not possible, they are returned as ``None``
+          without a warning. Requires GEOS >= 3.11 and shapely >= 2.1.
 
     """
     return GeometryArray(shapely.from_wkb(data, on_invalid=on_invalid), crs=crs)
@@ -249,6 +252,9 @@ def from_wkt(data, crs=None, on_invalid="raise"):
         - warn: a warning will be raised and invalid WKT geometries will be
           returned as ``None``.
         - ignore: invalid WKT geometries will be returned as ``None`` without a warning.
+        - fix: an effort is made to fix invalid input geometries (e.g. close
+          unclosed rings). If this is not possible, they are returned as ``None``
+          without a warning. Requires GEOS >= 3.11 and shapely >= 2.1.
 
     """
     return GeometryArray(shapely.from_wkt(data, on_invalid=on_invalid), crs=crs)
@@ -653,8 +659,18 @@ class GeometryArray(ExtensionArray):
     def normalize(self):
         return GeometryArray(shapely.normalize(self._data), crs=self.crs)
 
-    def make_valid(self):
-        return GeometryArray(shapely.make_valid(self._data), crs=self.crs)
+    def make_valid(self, method="linework", keep_collapsed=True):
+        kwargs = {}
+        if SHAPELY_GE_21:
+            kwargs["method"] = method
+            kwargs["keep_collapsed"] = keep_collapsed
+        else:
+            if method != "linework":
+                raise ValueError(
+                    "Only the 'linework' method is supported for shapely < 2.1."
+                )
+
+        return GeometryArray(shapely.make_valid(self._data, **kwargs), crs=self.crs)
 
     def reverse(self):
         return GeometryArray(shapely.reverse(self._data), crs=self.crs)
diff --git a/geopandas/base.py b/geopandas/base.py
index 20ec8d34..6e1efb1f 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -1776,11 +1776,11 @@ GeometryCollection
         """
         return _delegate_geo_method("normalize", self)
 
-    def make_valid(self):
-        """
-        Repairs invalid geometries.
+    def make_valid(self, method="linework", keep_collapsed=True):
+        """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.
@@ -1790,6 +1790,36 @@ GeometryCollection
         If the geometry must be split into multiple parts of different types
         to be made valid, then a GeometryCollection will be returned.
 
+        Two ``methods`` are available:
+
+        * the 'linework' algorithm tries to preserve every edge and vertex in
+          the input. It combines all rings into a set of noded lines and then
+          extracts valid polygons from that linework. An alternating even-odd
+          strategy is used to assign areas as interior or exterior. A
+          disadvantage is that for some relatively simple invalid geometries
+          this produces rather complex results.
+        * the 'structure' algorithm tries to reason from the structure of the
+          input to find the 'correct' repair: exterior rings bound area,
+          interior holes exclude area. It first makes all rings valid, then
+          shells are merged and holes are subtracted from the shells to
+          generate valid result. It assumes that holes and shells are correctly
+          categorized in the input geometry.
+
+        Parameters
+        ----------
+        method : {'linework', 'structure'}, default 'linework'
+            Algorithm to use when repairing geometry. 'structure'
+            requires GEOS >= 3.10 and shapely >= 2.1.
+
+            .. versionadded:: 1.1.0
+        keep_collapsed : bool, default True
+            For the 'structure' method, True will keep components that have
+            collapsed into a lower dimensionality. For example, a ring
+            collapsing to a line, or a line collapsing to a point. Must be True
+            for the 'linework' method.
+
+            .. versionadded:: 1.1.0
+
         Examples
         --------
         >>> from shapely.geometry import MultiPolygon, Polygon, LineString, Point
@@ -1812,7 +1842,9 @@ GeometryCollection
         2                           LINESTRING (0 0, 1 1, 1 0)
         dtype: geometry
         """
-        return _delegate_geo_method("make_valid", self)
+        return _delegate_geo_method(
+            "make_valid", self, method=method, keep_collapsed=keep_collapsed
+        )
 
     def reverse(self):
         """Returns a ``GeoSeries`` with the order of coordinates reversed.
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 2d056c99..4a90ecfb 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -400,6 +400,9 @@ class GeoSeries(GeoPandasBase, Series):
             - warn: a warning will be raised and invalid WKB geometries will be returned
               as None.
             - ignore: invalid WKB geometries will be returned as None without a warning.
+            - fix: an effort is made to fix invalid input geometries (e.g. close
+              unclosed rings). If this is not possible, they are returned as ``None``
+              without a warning. Requires GEOS >= 3.11 and shapely >= 2.1.
 
         kwargs
             Additional arguments passed to the Series constructor,
@@ -466,6 +469,9 @@ class GeoSeries(GeoPandasBase, Series):
               returned as ``None``.
             - ignore: invalid WKT geometries will be returned as ``None`` without a
               warning.
+            - fix: an effort is made to fix invalid input geometries (e.g. close
+              unclosed rings). If this is not possible, they are returned as ``None``
+              without a warning. Requires GEOS >= 3.11 and shapely >= 2.1.
 
         kwargs
             Additional arguments passed to the Series constructor,


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 2ef4b70a..89a30b45 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -998,6 +998,42 @@ class TestGeomMethods:
         assert_geoseries_equal(result, expected)
         assert result.is_valid.all()
 
+    @pytest.mark.parametrize(
+        "method, keep_collapsed, expected",
+        [
+            (
+                "linework",
+                True,
+                MultiLineString([[(0, 0), (1, 1)], [(1, 1), (1, 2)]]),
+            ),
+            (
+                "structure",
+                True,
+                LineString([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)]),
+            ),
+            ("structure", False, Polygon()),
+        ],
+    )
+    @pytest.mark.skipif(not SHAPELY_GE_21, reason="requires Shapely>=2.1")
+    def test_make_valid_method(self, method, keep_collapsed, expected):
+        polygon = Polygon([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)])
+        series = GeoSeries([polygon])
+        expected = GeoSeries([expected])
+        assert not series.is_valid.all()
+        result = series.make_valid(method=method, keep_collapsed=keep_collapsed)
+        assert_geoseries_equal(result, expected, check_geom_type=True)
+        assert result.is_valid.all()
+
+    @pytest.mark.skipif(SHAPELY_GE_21, reason="test for Shapely<2.1")
+    def test_make_valid_old_shapely(self):
+        """Only the 'linework' method is supported for shapely < 2.1."""
+        polygon = Polygon([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)])
+        series = GeoSeries([polygon])
+        with pytest.raises(
+            ValueError, match="Only the 'linework' method is supported for"
+        ):
+            series.make_valid(method="structure")
+
     def test_reverse(self):
         expected = GeoSeries(
             [