Repository: geopandas/geopandas
Pull request #2940 (2023-06-26 15:59:09)

ENH: add remove_repeated_points as a GeoSeries method
Implements the shapely remove_repeated_points method as part of https://github.com/geopandas/geopandas/issues/2010

martinfleis: @JamesGardiner is this ready for review?
JamesGardiner: @martinfleis not yet - I've been caught up with other activities the last few weeks. I'll look to get it out of draft over the weekend
JamesGardiner: @martinfleis I'm getting an odd failure in CI for this that I haven't had time to get to the bottom of yet. The issue is that in `Tests / ubuntu-latest, ci/envs/39-pd13-conda-forge.yaml` I get an error `TypeError: One of the arguments is of incorrect type. Please provide only Geometry objects.` - this only occurs in that specific test env and from what I can see the geometry is valid
martinfleis: @JamesGardiner That looks like some weird mismatch between shapely and pygeos when testing docstrings. I'll have a look. 
JamesGardiner: Thanks @martinfleis - I did some testing and it looks like it's isolated to the Linestring in the docstring
JamesGardiner: Looks like that worked @m-richards - thanks!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34d7102d..7a4e770f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ API changes:
 
 New methods:
 
+- Added ``remove_repeated_points`` method from shapely to GeoSeries/GeoDataframe (#2940).
 - Added ``frechet_distance()`` method from shapely to GeoSeries/GeoDataframe (#2929).
 - Added ``segmentize`` method from shapely to GeoSeries/GeoDataFrame (#2910).
 - Added ``extract_unique_points`` method from shapely to GeoSeries/GeoDataframe (#2915).
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index f6d1994f..c33dd575 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -98,6 +98,7 @@ Constructive methods and attributes
    GeoSeries.minimum_bounding_circle
    GeoSeries.minimum_rotated_rectangle
    GeoSeries.normalize
+   GeoSeries.remove_repeated_points
    GeoSeries.reverse
    GeoSeries.sample_points
    GeoSeries.segmentize
diff --git a/doc/source/docs/user_guide/geometric_manipulations.rst b/doc/source/docs/user_guide/geometric_manipulations.rst
index 4d49e1ed..0d5985e5 100644
--- a/doc/source/docs/user_guide/geometric_manipulations.rst
+++ b/doc/source/docs/user_guide/geometric_manipulations.rst
@@ -60,6 +60,11 @@ Constructive methods
   Returns a :class:`~geopandas.GeoSeries` containing a `Linestring` or `MultiLineString`
   geometry at a distance from the object on its right or its left side.
 
+.. method:: GeoSeries.remove_repeated_points
+
+   Returns a :class:`~geopandas.GeoSeries` containing a copy of the input geometry 
+   with repeated points removed.
+
 .. method:: GeoSeries.simplify(tolerance, preserve_topology=True)
 
   Returns a :class:`~geopandas.GeoSeries` containing a simplified representation of
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index 835e0590..5e4a0649 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -8,15 +8,13 @@ import warnings
 
 import numpy as np
 import pandas as pd
-
 import shapely
 import shapely.geometry
 import shapely.geos
 import shapely.ops
+import shapely.validation
 import shapely.wkb
 import shapely.wkt
-import shapely.validation
-
 from shapely.geometry.base import BaseGeometry
 
 from . import _compat as compat
@@ -731,6 +729,25 @@ def interiors(data):
     return data
 
 
+def remove_repeated_points(data, tolerance=0.0):
+    if compat.USE_SHAPELY_20:
+        return shapely.remove_repeated_points(data, tolerance=tolerance)
+    if compat.USE_PYGEOS and compat.SHAPELY_GE_20:
+        warnings.warn(
+            "PyGEOS does not support remove_repeated_points, and Shapely >= 2 is "
+            "installed, thus using Shapely and not PyGEOS to remove repeated points.",
+            stacklevel=4,
+        )
+        return pygeos.from_shapely(
+            shapely.remove_repeated_points(to_shapely(data), tolerance=tolerance)
+        )
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def representative_point(data):
     if compat.USE_PYGEOS:
         return pygeos.point_on_surface(data)
@@ -1093,9 +1110,10 @@ def _shapely_normalize(geom):
     """
     Small helper function for now because it is not yet available in Shapely.
     """
-    from shapely.geos import lgeos
+    from ctypes import c_int, c_void_p
+
     from shapely.geometry.base import geom_factory
-    from ctypes import c_void_p, c_int
+    from shapely.geos import lgeos
 
     lgeos._lgeos.GEOSNormalize_r.restype = c_int
     lgeos._lgeos.GEOSNormalize_r.argtypes = [c_void_p, c_void_p]
diff --git a/geopandas/array.py b/geopandas/array.py
index 2b813eb1..0903fdba 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -560,6 +560,12 @@ class GeometryArray(ExtensionArray):
         # no GeometryArray as result
         return vectorized.interiors(self._data)
 
+    def remove_repeated_points(self, tolerance=0.0):
+        return GeometryArray(
+            vectorized.remove_repeated_points(self._data, tolerance=tolerance),
+            crs=self.crs,
+        )
+
     def representative_point(self):
         return GeometryArray(vectorized.representative_point(self._data), crs=self.crs)
 
@@ -1181,7 +1187,7 @@ class GeometryArray(ExtensionArray):
         # note ExtensionArray usage of value_counts only specifies dropna,
         # so sort, normalize and bins are not arguments
         values = to_wkb(self)
-        from pandas import Series, Index
+        from pandas import Index, Series
 
         result = Series(values).value_counts(dropna=dropna)
         # value_counts converts None to nan, need to convert back for from_wkb to work
diff --git a/geopandas/base.py b/geopandas/base.py
index 62e6d8ea..63a0a6eb 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -899,6 +899,45 @@ GeometryCollection
         """
         return _delegate_property("interiors", self)
 
+    def remove_repeated_points(self, tolerance=0.0):
+        """Returns a ``GeoSeries`` containing a copy of the input geometry
+        with repeated points removed.
+
+        From the start of the coordinate sequence, each next point within the
+        tolerance is removed.
+
+        Removing repeated points with a non-zero tolerance may result in an invalid
+        geometry being returned.
+
+        Parameters
+        ----------
+        tolerance : float, default 0.0
+            Remove all points within this distance of each other. Use 0.0
+            to remove only exactly repeated points (the default).
+
+        Examples
+        --------
+
+        >>> from shapely import LineString, Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...        LineString([(0, 0), (0, 0), (1, 0)]),
+        ...        Polygon([(0, 0), (0, 0.5), (0, 1), (0.5, 1), (0,0)]),
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0    LINESTRING (0.000 0.000, 0.000 0.000, 1.000 0....
+        1    POLYGON ((0.000 0.000, 0.000 0.500, 0.000 1.00...
+        dtype: geometry
+
+        >>> s.remove_repeated_points(tolerance=0.0)
+        0                LINESTRING (0.000 0.000, 1.000 0.000)
+        1    POLYGON ((0.000 0.000, 0.000 0.500, 0.000 1.00...
+        dtype: geometry
+        """
+        return _delegate_geo_method("remove_repeated_points", self, tolerance=tolerance)
+
     def representative_point(self):
         """Returns a ``GeoSeries`` of (cheaply computed) points that are
         guaranteed to be within each geometry.


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index fa12657d..61220086 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -1897,3 +1897,49 @@ class TestGeomMethods:
         expected = GeoSeries([LineString([[-1, 0], [-1, 2], [1, 2]])])
         assert_geoseries_equal(expected, oc)
         assert isinstance(oc, GeoSeries)
+
+    @pytest.mark.skipif(
+        compat.SHAPELY_GE_20,
+        reason="remove_repeated_points is implemented for shapely >= 2.0",
+    )
+    def test_remove_repeated_points_not_implemented_shapely_pre2(self):
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 2.0 is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            self.squares.remove_repeated_points()
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS and compat.SHAPELY_GE_20),
+        reason="remove_repeated_points is only implemented for shapely >= 2.0",
+    )
+    def test_remove_repeated_points_pygeos_set_shapely_installed(self):
+        with pytest.warns(
+            UserWarning,
+            match=(
+                "PyGEOS does not support remove_repeated_points, "
+                "and Shapely >= 2 is installed"
+            ),
+        ):
+            self.g1.remove_repeated_points()
+
+    @pytest.mark.skipif(
+        not compat.USE_SHAPELY_20,
+        reason="remove_repeated_points is only implemented for shapely >= 2.0",
+    )
+    @pytest.mark.parametrize(
+        "geom,expected",
+        [
+            (
+                GeoSeries(LinearRing([(0, 0), (1, 2), (1, 2), (1, 3), (0, 0)])),
+                GeoSeries(LinearRing([(0, 0), (1, 2), (1, 3), (0, 0)])),
+            ),
+            (
+                GeoSeries(Polygon([(0, 0), (0, 0), (1, 0), (1, 1), (1, 0), (0, 0)])),
+                GeoSeries(Polygon([(0, 0), (1, 0), (1, 1), (1, 0), (0, 0)])),
+            ),
+        ],
+    )
+    def test_remove_repeated_points(self, geom, expected):
+        assert_geoseries_equal(expected, geom.remove_repeated_points(tolerance=0.0))