Repository: geopandas/geopandas

Issue:
API: Series alignment for non binary op methods
We have a few geo methods which take an additional arraylike input as a parameter; `interpolate`, `offset_curve` and `voronoi_polygons`, but we don't have a consistent way of dealing with alignment between `self.geometry` and the passed arraylike when it is a Series.

>Just noticing we have a bit of inconsistency of how to handle when we have a non binary op method expecting an arraylike. For binary ops, we  warn on alignment, here we auto align, in `interpolate`, distance throws a ValueError if it does not align, and for `offset_curve` we just feed unaligned GeometryArray to shapely.

I would prefer we warn or throw on unaligned data, but whatever we decide we should be consistent across similar situations.

_Originally posted by @m-richards in https://github.com/geopandas/geopandas/pull/3177#discussion_r1570298342_
            

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8021faad..2f725460 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,8 @@ API changes:
 - The `sjoin` method will now preserve the name of the index of the right
   GeoDataFrame, if it has one, instead of always using `"index_right"` as the
   name for the resulting column in the return value (#846, #2144).
+- GeoPandas now raises a ValueError when an unaligned Series is passed as a method
+  argument to avoid confusion of whether the automatic alignment happens or not (#3271).
 
 New methods:
 
diff --git a/geopandas/base.py b/geopandas/base.py
index 02eff5fd..8fa660db 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -88,13 +88,32 @@ def _delegate_property(op, this):
         return Series(data, index=this.index)
 
 
-def _delegate_geo_method(op, this, *args, **kwargs):
+def _delegate_geo_method(op, this, **kwargs):
     # type: (str, GeoSeries) -> GeoSeries
     """Unary operation that returns a GeoSeries"""
     from .geoseries import GeoSeries
+    from .geodataframe import GeoDataFrame
+
+    if isinstance(this, GeoSeries):
+        klass, var_name = "GeoSeries", "gs"
+    elif isinstance(this, GeoDataFrame):
+        klass, var_name = "GeoDataFrame", "gdf"
+    else:
+        klass, var_name = this.__class__.__name__, "this"
+
+    for key, val in kwargs.items():
+        if isinstance(val, pd.Series) and not val.index.equals(this.index):
+            raise ValueError(
+                f"Index of the Series passed as '{key}' does not match index of the "
+                f"{klass}. If you want both Series to be aligned, align them before "
+                f"passing them to this method as "
+                f"`{var_name}, {key} = {var_name}.align({key})`. If "
+                f"you want to ignore the index, pass the underlying array as '{key}' "
+                f"using `{key}.values`."
+            )
 
     a_this = GeometryArray(this.geometry.values)
-    data = getattr(a_this, op)(*args, **kwargs)
+    data = getattr(a_this, op)(**kwargs)
     return GeoSeries(data, index=this.index, crs=this.crs)
 
 
@@ -835,7 +854,9 @@ GeometryCollection
         2    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
         dtype: geometry
         """
-        return _delegate_geo_method("delaunay_triangles", self, tolerance, only_edges)
+        return _delegate_geo_method(
+            "delaunay_triangles", self, tolerance=tolerance, only_edges=only_edges
+        )
 
     @property
     def envelope(self):
@@ -1032,7 +1053,7 @@ GeometryCollection
         return _delegate_geo_method(
             "offset_curve",
             self,
-            distance,
+            distance=distance,
             quad_segs=quad_segs,
             join_style=join_style,
             mitre_limit=mitre_limit,
@@ -1479,7 +1500,9 @@ GeometryCollection
         1    POLYGON ((0 0, 5 0, 10 0, 10 5, 10 10, 5 10, 0...
         dtype: geometry
         """
-        return _delegate_geo_method("segmentize", self, max_segment_length)
+        return _delegate_geo_method(
+            "segmentize", self, max_segment_length=max_segment_length
+        )
 
     def transform(self, transformation, include_z=False):
         """Returns a ``GeoSeries`` with the transformation function
@@ -1519,7 +1542,9 @@ GeometryCollection
         0    POINT Z (1 1 1)
         dtype: geometry
         """
-        return _delegate_geo_method("transform", self, transformation, include_z)
+        return _delegate_geo_method(
+            "transform", self, transformation=transformation, include_z=include_z
+        )
 
     def force_2d(self):
         """Forces the dimensionality of a geometry to 2D.
@@ -3358,7 +3383,7 @@ GeometryCollection
         """
         return _binary_op("distance", self, other, align)
 
-    def hausdorff_distance(self, other, align=True, densify=None):
+    def hausdorff_distance(self, other, align=None, densify=None):
         """Returns a ``Series`` containing the Hausdorff distance to aligned `other`.
 
         The Hausdorff distance is the largest distance consisting of any point in `self`
@@ -3473,7 +3498,7 @@ GeometryCollection
         """
         return _binary_op("hausdorff_distance", self, other, align, densify=densify)
 
-    def frechet_distance(self, other, align=True, densify=None):
+    def frechet_distance(self, other, align=None, densify=None):
         """Returns a ``Series`` containing the Frechet distance to aligned `other`.
 
         The Fréchet distance is a measure of similarity: it is the greatest distance
@@ -4538,18 +4563,10 @@ GeometryCollection
         .. plot:: _static/code/buffer.py
 
         """
-        if isinstance(distance, pd.Series):
-            if not self.index.equals(distance.index):
-                raise ValueError(
-                    "Index values of distance sequence does "
-                    "not match index values of the GeoSeries"
-                )
-            distance = np.asarray(distance)
-
         return _delegate_geo_method(
             "buffer",
             self,
-            distance,
+            distance=distance,
             resolution=resolution,
             cap_style=cap_style,
             join_style=join_style,
@@ -4558,7 +4575,7 @@ GeometryCollection
             **kwargs,
         )
 
-    def simplify(self, *args, **kwargs):
+    def simplify(self, tolerance, preserve_topology=True):
         """Returns a ``GeoSeries`` containing a simplified representation of
         each geometry.
 
@@ -4606,7 +4623,9 @@ GeometryCollection
         1              LINESTRING (0 0, 0 20)
         dtype: geometry
         """
-        return _delegate_geo_method("simplify", self, *args, **kwargs)
+        return _delegate_geo_method(
+            "simplify", self, tolerance=tolerance, preserve_topology=preserve_topology
+        )
 
     def relate(self, other, align=None):
         """
@@ -4854,15 +4873,8 @@ GeometryCollection
         2                POINT (0 2)
         dtype: geometry
         """
-        if isinstance(distance, pd.Series):
-            if not self.index.equals(distance.index):
-                raise ValueError(
-                    "Index values of distance sequence does "
-                    "not match index values of the GeoSeries"
-                )
-            distance = np.asarray(distance)
         return _delegate_geo_method(
-            "interpolate", self, distance, normalized=normalized
+            "interpolate", self, distance=distance, normalized=normalized
         )
 
     def affine_transform(self, matrix):
@@ -4905,7 +4917,7 @@ GeometryCollection
         dtype: geometry
 
         """  # (E501 link is longer than max line length)
-        return _delegate_geo_method("affine_transform", self, matrix)
+        return _delegate_geo_method("affine_transform", self, matrix=matrix)
 
     def translate(self, xoff=0.0, yoff=0.0, zoff=0.0):
         """Returns a ``GeoSeries`` with translated geometries.
@@ -4943,7 +4955,7 @@ GeometryCollection
         dtype: geometry
 
         """  # (E501 link is longer than max line length)
-        return _delegate_geo_method("translate", self, xoff, yoff, zoff)
+        return _delegate_geo_method("translate", self, xoff=xoff, yoff=yoff, zoff=zoff)
 
     def rotate(self, angle, origin="center", use_radians=False):
         """Returns a ``GeoSeries`` with rotated geometries.
@@ -4994,7 +5006,7 @@ GeometryCollection
 
         """
         return _delegate_geo_method(
-            "rotate", self, angle, origin=origin, use_radians=use_radians
+            "rotate", self, angle=angle, origin=origin, use_radians=use_radians
         )
 
     def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin="center"):
@@ -5043,7 +5055,9 @@ GeometryCollection
         2    POLYGON ((6 -3, 8 0, 6 3, 6 -3))
         dtype: geometry
         """
-        return _delegate_geo_method("scale", self, xfact, yfact, zfact, origin=origin)
+        return _delegate_geo_method(
+            "scale", self, xfact=xfact, yfact=yfact, zfact=zfact, origin=origin
+        )
 
     def skew(self, xs=0.0, ys=0.0, origin="center", use_radians=False):
         """Returns a ``GeoSeries`` with skewed geometries.
@@ -5095,7 +5109,7 @@ GeometryCollection
         dtype: geometry
         """
         return _delegate_geo_method(
-            "skew", self, xs, ys, origin=origin, use_radians=use_radians
+            "skew", self, xs=xs, ys=ys, origin=origin, use_radians=use_radians
         )
 
     @property


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index f0742954..8473a9ff 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -962,6 +962,13 @@ class TestGeomMethods:
         assert_geoseries_equal(expected_g1, result_g1)
         assert_geoseries_equal(expected_g5, result_g5)
 
+    def test_segmentize_wrong_index(self):
+        with pytest.raises(
+            ValueError,
+            match="Index of the Series passed as 'max_segment_length' does not match",
+        ):
+            self.g1.segmentize(max_segment_length=Series([0.5, 0.5], index=[99, 98]))
+
     def test_transform(self):
         # Test 2D
         test_2d = GeoSeries(
@@ -1017,6 +1024,20 @@ class TestGeomMethods:
         s = GeoSeries(MultiPoint([(0, 0), (0, 3), (1, 1), (3, 0), (3, 3)]))
         assert_geoseries_equal(expected, s.concave_hull(ratio=ratio))
 
+    def test_concave_hull_wrong_index(self):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'ratio' does not match"
+        ):
+            self.g1.concave_hull(ratio=Series([0.0, 1.0], index=[99, 98]))
+
+        with pytest.raises(
+            ValueError,
+            match="Index of the Series passed as 'allow_holes' does not match",
+        ):
+            self.g1.concave_hull(
+                ratio=0.1, allow_holes=Series([True, False], index=[99, 98])
+            )
+
     def test_convex_hull(self):
         # the convex hull of a square should be the same as the square
         assert_geoseries_equal(self.squares, self.squares.convex_hull)
@@ -1043,6 +1064,18 @@ class TestGeomMethods:
         assert isinstance(dlt, GeoSeries)
         assert_series_equal(expected, dlt)
 
+    def test_delaunay_triangles_wrong_index(self):
+        with pytest.raises(
+            ValueError,
+            match="Index of the Series passed as 'only_edges' does not match",
+        ):
+            self.g3.delaunay_triangles(only_edges=Series([True, False], index=[99, 98]))
+
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'tolerance' does not match"
+        ):
+            self.g3.delaunay_triangles(tolerance=Series([0.1, 0.2], index=[99, 98]))
+
     def test_exterior(self):
         exp_exterior = GeoSeries([LinearRing(p.boundary) for p in self.g3])
         for expected, computed in zip(exp_exterior, self.g3.exterior):
@@ -1089,7 +1122,9 @@ class TestGeomMethods:
 
     def test_interpolate_distance_wrong_index(self):
         distances = Series([1, 2], index=[99, 98])
-        with pytest.raises(ValueError):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'distance' does not match"
+        ):
             self.g5.interpolate(distances)
 
     @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
@@ -1212,7 +1247,9 @@ class TestGeomMethods:
     def test_buffer_distance_wrong_index(self):
         original = GeoSeries([self.p0, self.p0], index=[0, 1])
         distances = Series(data=[1, 2], index=[99, 98])
-        with pytest.raises(ValueError):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'distance' does not match"
+        ):
             original.buffer(distances)
 
     def test_buffer_empty_none(self):
@@ -1236,6 +1273,17 @@ class TestGeomMethods:
         for r in record:
             assert "Geometry is in a geographic CRS." not in str(r.message)
 
+    def test_simplify(self):
+        s = GeoSeries([shapely.LineString([(0, 0), (1, 0.1), (2, 0)])])
+        e = GeoSeries([shapely.LineString([(0, 0), (2, 0)])])
+        assert_geoseries_equal(s.simplify(0.2), e)
+
+    def test_simplify_wrong_index(self):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'tolerance' does not match"
+        ):
+            self.g1.simplify(Series([0.1], index=[99]))
+
     def test_envelope(self):
         e = self.g3.envelope
         assert np.all(e.geom_equals(self.sq))
@@ -1758,6 +1806,12 @@ class TestGeomMethods:
         assert_geoseries_equal(expected, oc)
         assert isinstance(oc, GeoSeries)
 
+    def test_offset_curve_wrong_index(self):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'distance' does not match"
+        ):
+            GeoSeries([self.l1]).offset_curve(Series([1], index=[99]))
+
     @pytest.mark.skipif(shapely.geos_version < (3, 11, 0), reason="requires GEOS>=3.11")
     @pytest.mark.parametrize(
         "geom,expected",
@@ -1775,6 +1829,12 @@ class TestGeomMethods:
     def test_remove_repeated_points(self, geom, expected):
         assert_geoseries_equal(expected, geom.remove_repeated_points(tolerance=0.0))
 
+    def test_remove_repeated_points_wrong_index(self):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'tolerance' does not match"
+        ):
+            GeoSeries([self.l1]).remove_repeated_points(Series([1], index=[99]))
+
     def test_force_2d(self):
         expected = GeoSeries(
             [
@@ -1818,6 +1878,12 @@ class TestGeomMethods:
         )
         assert_geoseries_equal(expected, self.g1.force_3d([1, 2]))
 
+    def test_force_3d_wrong_index(self):
+        with pytest.raises(
+            ValueError, match="Index of the Series passed as 'z' does not match"
+        ):
+            self.g1.force_3d(Series([1], index=[99]))
+
     def test_line_merge(self):
         expected = GeoSeries(
             [