Repository: geopandas/geopandas
Pull request #3105 (2023-12-06 10:16:28)

ENH: add contains_properly predicate method
We have contains_properly already in sindex as an allowed predicate but did not have one explicitly exposed.

Part of #2010


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3473befc..c1d08b66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,8 +12,10 @@ 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 ``transform`` method from shapely to GeoSeries/GeoDataFrame (#3075).
+- Added `contains_properly` method from shapely to GeoSeries/GeoDataframe (#3105).
+- Added `transform` method from shapely to GeoSeries/GeoDataFrame (#3075).
 
 New features and improvements:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 1c42ac1b..e60ee73a 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -42,6 +42,7 @@ Unary predicates
 .. autosummary::
    :toctree: api/
 
+   GeoSeries.is_closed
    GeoSeries.is_empty
    GeoSeries.is_ring
    GeoSeries.is_simple
@@ -57,6 +58,7 @@ Binary predicates
    :toctree: api/
 
    GeoSeries.contains
+   GeoSeries.contains_properly
    GeoSeries.crosses
    GeoSeries.disjoint
    GeoSeries.geom_equals
diff --git a/geopandas/array.py b/geopandas/array.py
index e49245e3..4b80f8e4 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -674,6 +674,9 @@ class GeometryArray(ExtensionArray):
     def contains(self, other):
         return self._binary_method("contains", self, other)
 
+    def contains_properly(self, other):
+        return self._binary_method("contains_properly", self, other)
+
     def crosses(self, other):
         return self._binary_method("crosses", self, other)
 
diff --git a/geopandas/base.py b/geopandas/base.py
index 828f2369..62e0147f 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -475,6 +475,40 @@ GeometryCollection
         """
         return _delegate_property("is_ccw", self)
 
+    @property
+    def is_closed(self):
+        """Returns a ``Series`` of ``dtype('bool')`` with value ``True``
+        if a LineString's or LinearRing's first and last points are equal.
+
+        Returns False for any other geometry type.
+
+        Examples
+        --------
+        >>> from shapely.geometry import LineString, Point, Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(0, 0), (1, 1), (0, 1)]),
+        ...         Polygon([(0, 0), (0, 1), (1, 1), (0, 0)]),
+        ...         Point(3, 3)
+        ...     ]
+        ... )
+        >>> s
+        0    LINESTRING (0 0, 1 1, 0 1, 0 0)
+        1         LINESTRING (0 0, 1 1, 0 1)
+        2     POLYGON ((0 0, 0 1, 1 1, 0 0))
+        3                        POINT (3 3)
+        dtype: geometry
+
+        >>> s.is_closed
+        0     True
+        1    False
+        2    False
+        3    False
+        dtype: bool
+        """
+        return _delegate_property("is_closed", self)
+
     @property
     def has_z(self):
         """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
@@ -1583,10 +1617,132 @@ GeometryCollection
 
         See also
         --------
+        GeoSeries.contains_properly
         GeoSeries.within
         """
         return _binary_op("contains", self, other, align)
 
+    def contains_properly(self, other, align=True):
+        """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
+        each aligned geometry that is completely inside ``other``, with no common
+        boundary points.
+
+        Geometry A contains geometry B properly if B intersects the interior of A but
+        not the boundary (or exterior). This means that a geometry A does not “contain
+        properly” itself, which contrasts with the :meth:`~GeoSeries.contains` method,
+        where common points on the boundary are allowed.
+
+        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 test if it
+            is contained.
+        align : bool (default True)
+            If True, automatically aligns GeoSeries based on their indices.
+            If False, the order of elements is preserved.
+
+        Returns
+        -------
+        Series (bool)
+
+        Examples
+        --------
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         LineString([(0, 0), (0, 2)]),
+        ...         LineString([(0, 0), (0, 1)]),
+        ...         Point(0, 1),
+        ...     ],
+        ...     index=range(0, 4),
+        ... )
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (2, 2), (0, 2)]),
+        ...         Polygon([(0, 0), (1, 2), (0, 2)]),
+        ...         LineString([(0, 0), (0, 2)]),
+        ...         Point(0, 1),
+        ...     ],
+        ...     index=range(1, 5),
+        ... )
+
+        >>> s
+        0    POLYGON ((0 0, 1 1, 0 1, 0 0))
+        1             LINESTRING (0 0, 0 2)
+        2             LINESTRING (0 0, 0 1)
+        3                       POINT (0 1)
+        dtype: geometry
+
+        >>> s2
+        1    POLYGON ((0 0, 2 2, 0 2, 0 0))
+        2    POLYGON ((0 0, 1 2, 0 2, 0 0))
+        3             LINESTRING (0 0, 0 2)
+        4                       POINT (0 1)
+        dtype: geometry
+
+        We can check if each geometry of GeoSeries contains a single
+        geometry:
+
+        .. image:: ../../../_static/binary_op-03.svg
+           :align: center
+
+        >>> point = Point(0, 1)
+        >>> s.contains_properly(point)
+        0    False
+        1     True
+        2    False
+        3     True
+        dtype: bool
+
+        We can also check two GeoSeries against each other, row by row.
+        The GeoSeries above have different indices. We can either align both GeoSeries
+        based on index values and compare elements with the same index using
+        ``align=True`` or ignore index and compare elements based on their matching
+        order using ``align=False``:
+
+        .. image:: ../../../_static/binary_op-02.svg
+
+        >>> s2.contains_properly(s, align=True)
+        0    False
+        1    False
+        2    False
+        3     True
+        4    False
+        dtype: bool
+
+        >>> s2.contains_properly(s, align=False)
+        1    False
+        2    False
+        3    False
+        4     True
+        dtype: bool
+
+        Compare it to the result of :meth:`~GeoSeries.contains`:
+
+        >>> s2.contains(s, align=False)
+        1     True
+        2    False
+        3     True
+        4     True
+        dtype: bool
+
+        Notes
+        -----
+        This method works in a row-wise manner. It does not check if an element
+        of one GeoSeries ``contains_properly`` *any* element of the other one.
+
+        See also
+        --------
+        GeoSeries.contains
+        """
+        return _binary_op("contains_properly", self, other, align)
+
     def geom_equals(self, other, align=True):
         """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
         each aligned geometry equal to `other`.


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 3bc1f9fb..b5ef26a5 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -486,6 +486,21 @@ class TestGeomMethods:
         expected = [False, False, True, False, False, False, False]
         assert_array_dtype_equal(expected, self.g0.contains(self.g9, align=False))
 
+    def test_contains_properly(self):
+        expected = [False, False, True, False, False, False, False]
+        assert_array_dtype_equal(expected, self.g0.contains_properly(Point(0.25, 0.25)))
+
+        expected = [False, False, False, False, False, True, False, False]
+        with pytest.warns(UserWarning, match="The indices .+ different"):
+            assert_array_dtype_equal(
+                expected, self.g0.contains_properly(self.g9, align=True)
+            )
+
+        expected = [False, False, True, False, False, False, False]
+        assert_array_dtype_equal(
+            expected, self.g0.contains_properly(self.g9, align=False)
+        )
+
     def test_length(self):
         expected = Series(np.array([2 + np.sqrt(2), 4]), index=self.g1.index)
         self._test_unary_real("length", expected, self.g1)
@@ -783,6 +798,10 @@ class TestGeomMethods:
         expected = Series(np.array([False] * len(self.g1)), self.g1.index)
         self._test_unary_real("is_ccw", expected, self.g1)
 
+    def test_is_closed(self):
+        expected = Series(np.array([False, False]), self.g5.index)
+        self._test_unary_real("is_closed", expected, self.g5)
+
     def test_has_z(self):
         expected = Series([False, True], self.g_3d.index)
         self._test_unary_real("has_z", expected, self.g_3d)