Repository: geopandas/geopandas

Issue:
TST: assert_geodataframe_equal checks non active geometry columns twice
https://github.com/geopandas/geopandas/blob/a80081e52ed3bd069a75a4caaed44e45e35c647d/geopandas/testing.py#L318-L333

checks non active geometry columns twice. We should be able to drop all columns of geometrydtype for left2 and right2.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e712a80..8e927e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 New methods:
 
+- Added ``delaunay_triangles`` method from shapely to GeoSeries/GeoDataframe (#2907)
 - Added ``concave_hull`` method from shapely to GeoSeries/GeoDataframe. (#2903)
 - Added ``offset_curve`` method from shapely to GeoSeries/GeoDataframe. (#2902)
 
@@ -11,6 +12,7 @@ New features and improvements:
 
 Bug fixes:
 
+- ``assert_geodataframe_equal`` now handles GeoDataFrames with no active geometry (#2498)
 
 ## Version 0.13.2 (Jun 6, 2023)
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index a248a3e..f584479 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -89,6 +89,7 @@ Constructive methods and attributes
    GeoSeries.centroid
    GeoSeries.concave_hull
    GeoSeries.convex_hull
+   GeoSeries.delaunay_triangles
    GeoSeries.envelope
    GeoSeries.make_valid
    GeoSeries.minimum_bounding_circle
diff --git a/doc/source/docs/user_guide/geometric_manipulations.rst b/doc/source/docs/user_guide/geometric_manipulations.rst
index f3b944a..1df5e5f 100644
--- a/doc/source/docs/user_guide/geometric_manipulations.rst
+++ b/doc/source/docs/user_guide/geometric_manipulations.rst
@@ -38,6 +38,12 @@ Constructive methods
   number of points in the object is less than three. For two points,
   the convex hull collapses to a `LineString`; for 1, a `Point`.
 
+.. method:: GeoSeries.delaunay_triangles(tolerance, preserve_topology=True)
+
+  Returns a :class:`~geopandas.GeoSeries` consisting of polygons (default) or linestrings
+  (`only_edges=True`) representing the computed Delaunay triangulation around the vertices
+  of an input geometry.
+
 .. attribute:: GeoSeries.envelope
 
   Returns a :class:`~geopandas.GeoSeries` of geometries representing the point or
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index e185f28..92d3666 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -637,6 +637,18 @@ def convex_hull(data):
         return _unary_geo("convex_hull", data)
 
 
+def delaunay_triangles(data, tolerance, only_edges):
+    if compat.USE_SHAPELY_20:
+        return shapely.delaunay_triangles(data, tolerance, only_edges)
+    elif compat.USE_PYGEOS:
+        return pygeos.delaunay_triangles(data, tolerance, only_edges)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def envelope(data):
     if compat.USE_SHAPELY_20:
         return shapely.envelope(data)
diff --git a/geopandas/array.py b/geopandas/array.py
index 81acfb5..37dfcfd 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -519,6 +519,12 @@ class GeometryArray(ExtensionArray):
     def convex_hull(self):
         return GeometryArray(vectorized.convex_hull(self._data), crs=self.crs)
 
+    def delaunay_triangles(self, tolerance, only_edges):
+        return GeometryArray(
+            vectorized.delaunay_triangles(self._data, tolerance, only_edges),
+            crs=self.crs,
+        )
+
     @property
     def envelope(self):
         return GeometryArray(vectorized.envelope(self._data), crs=self.crs)
diff --git a/geopandas/base.py b/geopandas/base.py
index fac044d..09d641f 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -603,6 +603,56 @@ GeometryCollection
         """
         return _delegate_property("convex_hull", self)
 
+    def delaunay_triangles(self, tolerance=0.0, only_edges=False):
+        """Returns a ``GeoSeries`` consisting of objects representing
+        the computed Delaunay triangulation around the vertices of
+        an input geometry.
+
+        The output is a ``GeometryCollection`` containing polygons
+        (default) or linestrings (see only_edges).
+
+        Returns an empty GeometryCollection if an input geometry
+        contains less than 3 vertices.
+
+        Parameters
+        ----------
+        tolerance : float | array-like, default 0.0
+            Snap input vertices together if their distance is less than this value.
+        only_edges : bool | array_like, (optional, default False)
+            If set to True, the triangulation will return a collection of
+            linestrings instead of polygons.
+
+        Examples
+        --------
+
+        >>> from shapely import LineString, MultiPoint, Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         MultiPoint([(50, 30), (60, 30), (100, 100)]),
+        ...         Polygon([(50, 30), (60, 30), (100, 100), (50, 30)]),
+        ...         LineString([(50, 30), (60, 30), (100, 100)]),
+        ...     ]
+        ... )
+        >>> s
+        0   MULTIPOINT (50.000 30.000, 60.000 30.000, 100....
+        1   POLYGON ((50.000 30.000, 60.000 30.000, 100.00...
+        2   LINESTRING (50.000 30.000, 60.000 30.000, 100....
+        dtype: geometry
+
+        >>> s.delaunay_triangles()
+        0    GEOMETRYCOLLECTION (POLYGON ((50.000 30.000, 6...
+        1    GEOMETRYCOLLECTION (POLYGON ((50.000 30.000, 6...
+        2    GEOMETRYCOLLECTION (POLYGON ((50.000 30.000, 6...
+        dtype: geometry
+
+        >>> s.delaunay_triangles(only_edges=True)
+        0    MULTILINESTRING ((50.000 30.000, 100.000 100.0...
+        1    MULTILINESTRING ((50.000 30.000, 100.000 100.0...
+        2    MULTILINESTRING ((50.000 30.000, 100.000 100.0...
+        dtype: geometry
+        """
+        return _delegate_geo_method("delaunay_triangles", self, tolerance, only_edges)
+
     @property
     def envelope(self):
         """Returns a ``GeoSeries`` of geometries representing the envelope of


Test patch:
diff --git a/geopandas/testing.py b/geopandas/testing.py
index ae89cd0..54b6868 100644
--- a/geopandas/testing.py
+++ b/geopandas/testing.py
@@ -288,8 +288,19 @@ def assert_geodataframe_equal(
         assert isinstance(left, type(right))
 
         if check_crs:
+            # allow if neither left and right has an active geometry column
+            if (
+                left._geometry_column_name is None
+                and right._geometry_column_name is None
+            ):
+                pass
+            elif (
+                left._geometry_column_name not in left.columns
+                and right._geometry_column_name not in right.columns
+            ):
+                pass
             # no crs can be either None or {}
-            if not left.crs and not right.crs:
+            elif not left.crs and not right.crs:
                 pass
             else:
                 assert left.crs == right.crs
@@ -328,9 +339,12 @@ def assert_geodataframe_equal(
                 check_crs=check_crs,
             )
 
+    # ensure the active geometry column is the same
+    assert left._geometry_column_name == right._geometry_column_name
+
     # drop geometries and check remaining columns
-    left2 = left.drop([left._geometry_column_name], axis=1)
-    right2 = right.drop([right._geometry_column_name], axis=1)
+    left2 = left.select_dtypes(exclude="geometry")
+    right2 = right.select_dtypes(exclude="geometry")
     assert_frame_equal(
         left2,
         right2,
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 6d24a4f..7e1cad2 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -11,6 +11,7 @@ import shapely
 from shapely.geometry import (
     LinearRing,
     LineString,
+    MultiLineString,
     MultiPoint,
     Point,
     Polygon,
@@ -26,7 +27,7 @@ from geopandas.base import GeoPandasBase
 from geopandas.testing import assert_geodataframe_equal
 from geopandas.tests.util import assert_geoseries_equal, geom_almost_equals, geom_equals
 from geopandas import _compat as compat
-from pandas.testing import assert_frame_equal, assert_series_equal, assert_index_equal
+from pandas.testing import assert_frame_equal, assert_index_equal, assert_series_equal
 import pytest
 
 
@@ -834,6 +835,49 @@ class TestGeomMethods:
         # the convex hull of a square should be the same as the square
         assert_geoseries_equal(self.squares, self.squares.convex_hull)
 
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="delaunay_triangles not implemented for shapely<2",
+    )
+    def test_delaunay_triangles(self):
+        expected = GeoSeries(
+            [
+                GeometryCollection([Polygon([(0, 0), (1, 0), (1, 1), (0, 0)])]),
+                GeometryCollection([Polygon([(0, 1), (0, 0), (1, 1), (0, 1)])]),
+            ]
+        )
+        dlt = self.g3.delaunay_triangles()
+        assert isinstance(dlt, GeoSeries)
+        assert_series_equal(expected, dlt)
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="delaunay_triangles not implemented for shapely<2",
+    )
+    def test_delaunay_triangles_pass_kwargs(self):
+        expected = GeoSeries(
+            [
+                MultiLineString([[(0, 0), (1, 1)], [(0, 0), (1, 0)], [(1, 0), (1, 1)]]),
+                MultiLineString([[(0, 1), (1, 1)], [(0, 0), (0, 1)], [(0, 0), (1, 1)]]),
+            ]
+        )
+        dlt = self.g3.delaunay_triangles(only_edges=True)
+        assert isinstance(dlt, GeoSeries)
+        assert_series_equal(expected, dlt)
+
+    @pytest.mark.skipif(
+        compat.USE_PYGEOS or compat.USE_SHAPELY_20,
+        reason="delaunay_triangles implemented for shapely>2",
+    )
+    def test_delaunay_triangles_shapely_pre20(self):
+        s = GeoSeries([Point(1, 1)])
+        with pytest.raises(
+            NotImplementedError,
+            match=f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed",
+        ):
+            s.delaunay_triangles()
+
     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):
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 99da218..c10a418 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -776,9 +776,7 @@ def test_pivot(df):
     # pivot failing due to creating a MultiIndex
     result = df.pivot(columns="value1")
     expected = GeoDataFrame(pd.DataFrame(df).pivot(columns="value1"))
-    # TODO assert_geodataframe_equal crashes
-    assert isinstance(result, GeoDataFrame)
-    assert_frame_equal(result, expected)
+    assert_geodataframe_equal(result, expected)
 
 
 def test_preserve_attrs(df):
diff --git a/geopandas/tests/test_testing.py b/geopandas/tests/test_testing.py
index 1783dd2..b119f72 100644
--- a/geopandas/tests/test_testing.py
+++ b/geopandas/tests/test_testing.py
@@ -3,7 +3,8 @@ import warnings
 import numpy as np
 
 from shapely.geometry import Point, Polygon
-from pandas import Series
+import pandas as pd
+from pandas import DataFrame, Series
 
 from geopandas import GeoDataFrame, GeoSeries
 from geopandas.array import from_shapely
@@ -139,3 +140,44 @@ def test_almost_equal_but_not_equal():
     assert_geoseries_equal(s_origin, s_almost_origin, check_less_precise=True)
     with pytest.raises(AssertionError):
         assert_geoseries_equal(s_origin, s_almost_origin)
+
+
+def test_geodataframe_no_active_geometry_column():
+    def create_dataframe():
+        gdf = GeoDataFrame({"value": [1, 2], "geometry": [Point(1, 1), Point(2, 2)]})
+        gdf["geom2"] = GeoSeries([Point(3, 3), Point(4, 4)])
+        return gdf
+
+    # no active geometry column (None)
+    df1 = create_dataframe()
+    df1._geometry_column_name = None
+    df2 = create_dataframe()
+    df2._geometry_column_name = None
+    assert_geodataframe_equal(df1, df2)
+
+    # active geometry column ("geometry") not present
+    df1 = create_dataframe()[["value", "geom2"]]
+    df2 = create_dataframe()[["value", "geom2"]]
+    assert_geodataframe_equal(df1, df2)
+
+    df1 = GeoDataFrame(create_dataframe()[["value"]])
+    df2 = GeoDataFrame(create_dataframe()[["value"]])
+    assert_geodataframe_equal(df1, df2)
+
+
+def test_geodataframe_multiindex():
+    def create_dataframe():
+        gdf = DataFrame([[Point(0, 0), Point(1, 1)], [Point(2, 2), Point(3, 3)]])
+        gdf = GeoDataFrame(gdf.astype("geometry"))
+        gdf.columns = pd.MultiIndex.from_product([["geometry"], [0, 1]])
+        return gdf
+
+    df1 = create_dataframe()
+    df2 = create_dataframe()
+    assert_geodataframe_equal(df1, df2)
+
+    df1 = create_dataframe()
+    df1._geometry_column_name = None
+    df2 = create_dataframe()
+    df2._geometry_column_name = None
+    assert_geodataframe_equal(df1, df2)