Repository: geopandas/geopandas
Pull request #3560 (2025-05-10 11:35:14)

ENH: add geom_equals_identical
xref #2010


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fa3c6f5..8801ea28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ New features and improvements:
 - Added ``method`` and ``keep_collapsed`` argument to ``make_valid`` (#3548).
 - Added `simplify_coverage` method for topological simplification of polygonal coverages
   to GeoSeries/GeoDataframe (#3541).
+- Added `geom_equals_identical` method exposing `equals_identical` from shapely to GeoSeries/GeoDataFrame (#3560).
 
 Bug fixes:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 4782d141..ac293ef8 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -73,6 +73,7 @@ Binary predicates
    GeoSeries.dwithin
    GeoSeries.geom_equals
    GeoSeries.geom_equals_exact
+   GeoSeries.geom_equals_identical
    GeoSeries.intersects
    GeoSeries.overlaps
    GeoSeries.touches
diff --git a/geopandas/array.py b/geopandas/array.py
index 1f93b606..f873a578 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -805,6 +805,11 @@ class GeometryArray(ExtensionArray):
     def geom_equals_exact(self, other, tolerance):
         return self._binary_method("equals_exact", self, other, tolerance=tolerance)
 
+    def geom_equals_identical(self, other):
+        if not SHAPELY_GE_21:
+            raise ImportError("'geom_equals_identical' requires shapely>=2.1.")
+        return self._binary_method("equals_identical", self, other)
+
     #
     # Binary operations that return new geometries
     #
diff --git a/geopandas/base.py b/geopandas/base.py
index ba775f52..c54b3a07 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -3016,6 +3016,7 @@ GeometryCollection
         See also
         --------
         GeoSeries.geom_equals_exact
+        GeoSeries.geom_equals_identical
 
         """
         return _binary_op("geom_equals", self, other, align)
@@ -3080,11 +3081,83 @@ GeometryCollection
         See also
         --------
         GeoSeries.geom_equals
+        GeoSeries.geom_equals_identical
         """
         return _binary_op(
             "geom_equals_exact", self, other, tolerance=tolerance, align=align
         )
 
+    def geom_equals_identical(self, other, align=None):
+        """Return True for all geometries that are identical aligned *other*, else
+        False.
+
+        This function verifies whether geometries are pointwise equivalent by checking
+        that the structure, ordering, and values of all vertices are identical in all
+        dimensions.
+
+        Similarly to :meth:`geom_equals_exact`, this function uses exact coordinate
+        equality and requires coordinates to be in the same order for all components
+        (vertices, rings, or parts) of a geometry. However, in contrast to
+        :meth:`geom_equals_exact`, this function does not allow specifying specify
+        a tolerance, and additionally requires all dimensions to be the same
+        (:meth:`geom_equals_exact` ignores the Z and M dimensions), where NaN values
+        are considered to be equal to other NaN values.
+
+        This function is the vectorized equivalent of scalar equality of geometry
+        objects (``a == b``, i.e. ``__eq__``).
+
+        The operation works on 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 compare to.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices.
+            If False, the order of elements is preserved. None defaults to True.
+
+        Returns
+        -------
+        Series (bool)
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Point(0, 1.1),
+        ...         Point(0, 1.0),
+        ...         Point(0, 1.2),
+        ...     ]
+        ... )
+        >>> s
+        0    POINT (0 1.1)
+        1      POINT (0 1)
+        2    POINT (0 1.2)
+        dtype: geometry
+
+
+        >>> s.geom_equals_identical(Point(0, 1))
+        0    False
+        1     True
+        2    False
+        dtype: bool
+
+        Notes
+        -----
+        This method works in a row-wise manner. It does not check if an element
+        of one GeoSeries is equal to *any* element of the other one.
+
+        See also
+        --------
+        GeoSeries.geom_equals
+        GeoSeries.geom_equals_exact
+        """
+        return _binary_op("geom_equals_identical", self, other, align=align)
+
     def crosses(self, other, align=None):
         """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
         each aligned geometry that cross `other`.
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 7ced15a9..30a48bbc 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -763,6 +763,12 @@ def _read_parquet(
     schema, metadata = _read_parquet_schema_and_metadata(path, filesystem)
 
     geo_metadata = _validate_and_decode_metadata(metadata)
+    if len(geo_metadata["columns"]) == 0:
+        raise ValueError(
+            """No geometry columns are included in the columns read from
+            the Parquet/Feather file.  To read this file without geometry columns,
+            use pandas.read_parquet/read_feather() instead."""
+        )
 
     bbox_filter = (
         _get_parquet_bbox_filter(geo_metadata, bbox) if bbox is not None else None


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index ad5fd3dc..5d9c4830 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -1321,3 +1321,20 @@ def test_read_parquet_bbox_points(tmp_path):
     assert len(result) == 10
     result = geopandas.read_parquet(tmp_path / "test.parquet", bbox=(3, 3, 5, 5))
     assert len(result) == 3
+
+
+def test_non_geo_parquet_read_with_proper_error(tmp_path):
+    # https://github.com/geopandas/geopandas/issues/3556
+
+    gdf = geopandas.GeoDataFrame(
+        {"col": [1, 2, 3]},
+        geometry=geopandas.points_from_xy([1, 2, 3], [1, 2, 3]),
+        crs="EPSG:4326",
+    )
+    del gdf["geometry"]
+
+    gdf.to_parquet(tmp_path / "test_no_geometry.parquet")
+    with pytest.raises(
+        ValueError, match="No geometry columns are included in the columns read"
+    ):
+        geopandas.read_parquet(tmp_path / "test_no_geometry.parquet")
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
index f9fd883d..3aae4608 100644
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -159,6 +159,18 @@ class TestSeries:
             self.a1.geom_equals_exact(self.a2, 0.001, align=False), [False, False]
         )
 
+    @pytest.mark.skipif(not compat.SHAPELY_GE_21, reason="requires Shapely>=2.1")
+    def test_geom_equals_identical(self):
+        assert np.all(self.g1.geom_equals_identical(self.g1))
+        assert_array_equal(self.g1.geom_equals_identical(self.sq), [False, True])
+        assert_array_equal(
+            self.a1.geom_equals_identical(self.a2, align=True),
+            [False, True, False],
+        )
+        assert_array_equal(
+            self.a1.geom_equals_identical(self.a2, align=False), [False, False]
+        )
+
     def test_equal_comp_op(self):
         s = GeoSeries([Point(x, x) for x in range(3)])
         res = s == Point(1, 1)