Repository: geopandas/geopandas
Pull request #3086 (2023-11-27 20:42:06)

ENH: expose snap
xref #2010

I have also noticed that the changelog is not properly linted and did that while adding a note for the PR.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0b406dc..a6bbc1fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,12 +9,14 @@ Notes on dependencies:
   rtree package has also been removed. (#3035)
 
 New methods:
+
 - Added `count_coordinates` method from shapely to GeoSeries/GeoDataframe (#3026).
 - Added `minimum_clearance` method from shapely to GeoSeries/GeoDataframe (#2989).
 - Added `is_ccw` method from shapely to GeoSeries/GeoDataframe (#3027).
 - Added `is_closed` attribute from shapely to GeoSeries/GeoDataframe (#3092).
 - Added `force_2d` and `force_3d` methods from shapely to GeoSeries/GeoDataframe (#3090).
 - Added `contains_properly` method from shapely to GeoSeries/GeoDataframe (#3105).
+- Added `snap` method from shapely to GeoSeries/GeoDataframe (#3086).
 - Added `transform` method from shapely to GeoSeries/GeoDataFrame (#3075).
 
 New features and improvements:
@@ -28,11 +30,13 @@ New features and improvements:
   the specified columns (#3101).
 
 Potentially breaking changes:
+
 - reading a data source that does not have a geometry field using ``read_file``
   now returns a Pandas DataFrame instead of a GeoDataFrame with an empty
   ``geometry`` column.
 
 Bug fixes:
+
 - Fix `GeoDataFrame.merge()` incorrectly returning a `DataFrame` instead of a
   `GeoDataFrame` when the `suffixes` argument is applied to the active
   geometry column (#2933).
@@ -78,6 +82,7 @@ New features and improvements:
   for files with the `.fgb` extension (#2958)
 
 Bug fixes:
+
 - Fix ambiguous error when GeoDataFrame is initialized with a column called ``"crs"`` (#2944)
 - Fix a color assignment in ``explore`` when using ``UserDefined`` bins (#2923)
 - Fix bug in `apply` with `axis=1` where the given user defined function returns nested
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index e60ee73a..5a3dbc30 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -91,8 +91,8 @@ Constructive methods and attributes
 .. autosummary::
    :toctree: api/
 
-   GeoSeries.buffer
    GeoSeries.boundary
+   GeoSeries.buffer
    GeoSeries.centroid
    GeoSeries.concave_hull
    GeoSeries.convex_hull
@@ -112,6 +112,7 @@ Constructive methods and attributes
    GeoSeries.segmentize
    GeoSeries.shortest_line
    GeoSeries.simplify
+   GeoSeries.snap
    GeoSeries.transform
 
 Affine transformations
diff --git a/geopandas/array.py b/geopandas/array.py
index 9774fa60..c4365034 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -752,6 +752,11 @@ class GeometryArray(ExtensionArray):
             self._binary_method("shortest_line", self, other), crs=self.crs
         )
 
+    def snap(self, other, tolerance):
+        return GeometryArray(
+            self._binary_method("snap", self, other, tolerance=tolerance), crs=self.crs
+        )
+
     #
     # Other operations
     #
diff --git a/geopandas/base.py b/geopandas/base.py
index 62e0147f..c641cfa7 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -49,12 +49,12 @@ def _delegate_binary_method(op, this, other, align, *args, **kwargs):
     return data, this.index
 
 
-def _binary_geo(op, this, other, align):
+def _binary_geo(op, this, other, align, *args, **kwargs):
     # type: (str, GeoSeries, GeoSeries) -> GeoSeries
     """Binary operation on GeoSeries objects that returns a GeoSeries"""
     from .geoseries import GeoSeries
 
-    geoms, index = _delegate_binary_method(op, this, other, align)
+    geoms, index = _delegate_binary_method(op, this, other, align, *args, **kwargs)
     return GeoSeries(geoms, index=index, crs=this.crs)
 
 
@@ -3844,6 +3844,107 @@ GeometryCollection
         """
         return _binary_geo("shortest_line", self, other, align)
 
+    def snap(self, other, tolerance, align=True):
+        """Snaps an input geometry to reference geometry’s vertices.
+
+        Vertices of the first geometry are snapped to vertices of the second. geometry,
+        returning a new geometry; the input geometries are not modified. The result
+        geometry is the input geometry with the vertices snapped. If no snapping occurs
+        then the input geometry is returned unchanged. The tolerance is used to control
+        where snapping is performed.
+
+        Where possible, this operation tries to avoid creating invalid geometries;
+        however, it does not guarantee that output geometries will be valid. It is the
+        responsibility of the caller to check for and handle invalid geometries.
+
+        Because too much snapping can result in invalid geometries being created,
+        heuristics are used to determine the number and location of snapped vertices
+        that are likely safe to snap. These heuristics may omit some potential snaps
+        that are otherwise within the tolerance.
+
+        The operation works in a 1-to-1 row-wise manner:
+
+        .. image:: ../../../_static/binary_op-01.svg
+        :align: center
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The Geoseries (elementwise) or geometric object to snap to.
+        tolerance : float or array like
+            Maximum distance between vertices that shall be snapped
+        align : bool (default True)
+            If True, automatically aligns GeoSeries based on their indices.
+            If False, the order of elements is preserved.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Point(0.5, 2.5),
+        ...         LineString([(0.1, 0.1), (0.49, 0.51), (1.01, 0.89)]),
+        ...         Polygon([(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)]),
+        ...     ],
+        ... )
+        >>> s
+        0                               POINT (0.5 2.5)
+        1    LINESTRING (0.1 0.1, 0.49 0.51, 1.01 0.89)
+        2       POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))
+        dtype: geometry
+
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         Point(0, 2),
+        ...         LineString([(0, 0), (0.5, 0.5), (1.0, 1.0)]),
+        ...         Point(8, 10),
+        ...     ],
+        ...     index=range(1, 4),
+        ... )
+        >>> s2
+        1                       POINT (0 2)
+        2    LINESTRING (0 0, 0.5 0.5, 1 1)
+        3                      POINT (8 10)
+        dtype: geometry
+
+        We can snap each geometry to a single shapely geometry:
+
+        .. image:: ../../../_static/binary_op-03.svg
+           :align: center
+
+        >>> s.snap(Point(0, 2), tolerance=1)
+        0                                     POINT (0 2)
+        1      LINESTRING (0.1 0.1, 0.49 0.51, 1.01 0.89)
+        2    POLYGON ((0 0, 0 2, 0 10, 10 10, 10 0, 0 0))
+        dtype: geometry
+
+        We can also snap two GeoSeries to each other, row by row.
+        The GeoSeries above have different indices. We can either align both GeoSeries
+        based on index values and snap elements with the same index using
+        ``align=True`` or ignore index and snap elements based on their matching
+        order using ``align=False``:
+
+        .. image:: ../../../_static/binary_op-02.svg
+
+        >>> s.snap(s2, tolerance=1, align=True)
+        0                                                 None
+        1           LINESTRING (0.1 0.1, 0.49 0.51, 1.01 0.89)
+        2    POLYGON ((0.5 0.5, 1 1, 0 10, 10 10, 10 0, 0.5...
+        3                                                 None
+        dtype: geometry
+
+        >>> s.snap(s2, tolerance=1, align=False)
+        0                                      POINT (0 2)
+        1                   LINESTRING (0 0, 0.5 0.5, 1 1)
+        2    POLYGON ((0 0, 0 10, 8 10, 10 10, 10 0, 0 0))
+        dtype: geometry
+        """
+        return _binary_geo("snap", self, other, align, tolerance=tolerance)
+
     #
     # Other operations
     #


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index b5ef26a5..94ed0f6b 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -388,6 +388,20 @@ class TestGeomMethods:
             expected, self.crossed_lines.shortest_line(crossed_lines_inv, align=False)
         )
 
+    def test_snap(self):
+        expected = GeoSeries([Polygon([(0, 0.5), (1, 0), (1, 1), (0, 0.5)]), None])
+        assert_array_dtype_equal(
+            expected, self.na_none.snap(Point(0, 0.5), tolerance=1)
+        )
+
+        expected = GeoSeries(
+            [
+                Point((5, 5)),
+                Polygon([(0, 2), (0, 0), (3, 0), (3, 3), (0, 2)]),
+            ]
+        )
+        assert_array_dtype_equal(expected, self.g6.snap(self.g7, tolerance=3))
+
     def test_geo_op_empty_result(self):
         l1 = LineString([(0, 0), (1, 1)])
         l2 = LineString([(2, 2), (3, 3)])