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:
I think it should behave the same as binary predicates do when you don't specify `align`, which means align and warn. 
This is the current situation.


| function               | param                        | solution                              | version   |
| ---------------------- | ---------------------------- | ------------------------------------- | --------- |
| concave_hull           | ratio, allow_holes           | ignore index                          | 0.14      |
| delaunay_triangles     | tolerance, only_edges        | ignore index                          | 0.14      |
| offset_curve           | distance, quad_seqs...       | ignore index                          | 0.14      |
| remove_repeated_points | tolerance                    | ignore index                          | 0.14      |
| segmentize             | max_segment_length           | ignore index                          | 0.14      |
| force_3d               | z                            | ignore index                          | main      |
| buffer                 | distance...                  | raise for distance, ignore for others | 0.5       |
| simplify               | tolerance, preserve_topology | ignore index                          | shapely 2 |
| interpolate            | distance                     | raise                                 | 0.5       |




So we don't align in any case. We either raise or ignore the index. Ignoring the index does not feel as a correct behaviour because users may expect alignment, which silently does not happen resulting in misaligned args. I think that we have two options:

1. raise when the index is not equal like we do in buffer (for distance). This does not require any additional keywords and users can align themselves if needed. We can include a note on how in the error message.
2. add `align` keyword everywhere and mirror the behaviour of binary geometry ops.

Given most of the affected methods are from 0.14, I think we can just do the hard breaking change here, whatever we decide. I am leaning towards the first option as it is easier but if we want to do full consistency, the second is probably better.

I would also hold with merging of shapely parity PRs affected by this so I can update them based on the selected behaviour.

I'm leaning towards option 1, because we should be able to give a nice instructive error for this (here's how to align, and here's how to convert to an array if you didn't want to align). Personally feel adding an align keyword (and remembering to add an align keyword for any future methods) doesn't provide enough convenience to warrant extra complexity (even if it's not super complex). 

From the perspective of a breaking change, I also like that we go from ignore -> raise, as then users need to deal with it in cases where it matters, vs ignore -> align (or align and and warn) by default which could be missed or ignored (appreciate not everyone would agree with that though)
Yes, also agreed to go with option 1, and to do this as a breaking change. Adding an `align` keyword everywhere seems a lot of bloat for a corner case that users can solve themselves (either aligning manually first, or passing the `.values`), and in addition can also be ambiguous if there are multiple keywords where the value could be aligned or not.
Cool. I'll shoot a PR for that in coming days and update relevant PRs accordingly.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79bca301da..a7c54ae329 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 02eff5fddb..0a9280f0a8 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 @@ def delaunay_triangles(self, tolerance=0.0, only_edges=False):
         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 @@ def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.
         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 @@ def segmentize(self, max_segment_length):
         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 @@ def transform(self, transformation, include_z=False):
         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.
@@ -4538,18 +4563,10 @@ def buffer(
         .. 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 @@ def buffer(
             **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 @@ def simplify(self, *args, **kwargs):
         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 @@ def interpolate(self, distance, normalized=False):
         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 @@ def affine_transform(self, matrix):
         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 @@ def translate(self, xoff=0.0, yoff=0.0, zoff=0.0):
         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 @@ def rotate(self, angle, origin="center", use_radians=False):
 
         """
         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 @@ def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin="center"):
         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 @@ def skew(self, xs=0.0, ys=0.0, origin="center", use_radians=False):
         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 f074295439..8473a9ff79 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -962,6 +962,13 @@ def test_segmentize_linestrings(self):
         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 @@ def test_concave_hull_accepts_kwargs(self, expected_series, ratio):
         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 @@ def test_delaunay_triangles_pass_kwargs(self):
         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 @@ def test_interpolate_distance_wrong_length(self):
 
     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 @@ def test_buffer_distance_wrong_length(self):
     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 @@ def test_buffer_crs_warn(self):
         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 @@ def test_offset_curve(self):
         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 @@ def test_offset_curve(self):
     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 @@ def test_force_3d(self):
         )
         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(
             [