Repository: geopandas/geopandas

Issue:
ENH: allow all predicates in sindex.query
We have a hard-coded list of valid predicates for `sindex.query` and `query_bulk`. But pygeos now supports more of them (`contains_properly`, maybe more I did not check). Would be ideal to let pygeos/rtree to raise an error if an unsupported predicate is passed, so we don't have to keep track of what is supported where.

I vaguely remember some discussion on this, but I could not find it, so I am not sure if there is not a good reason for this solution (maybe @adriangb remember?).

https://github.com/geopandas/geopandas/blob/aa687662791863d55d3e27e8d468d9eb2eed82bd/geopandas/sindex.py#L8-L16

Discussion:
adriangb: Tests are passing now. CCing @martinfleis @brendan-ward @jorisvandenbossche for some review.
adriangb: > Shapely does not seem to list supported binary predicates. At least I have not found it, so we'll have to hard-code that one. But that is fine since we'll soon switch to shapely 2.0 anyway.

Yeah it's not very clean as well (at least the current implementation). Do you want to switch from `ValueError` to `NotImplementedError` for invalid predicates? Otherwise I'll just revert all of the error code to its previous state.

Patch:
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
index e762782d..0716bc98 100644
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -5,17 +5,6 @@ import numpy as np
 from . import _compat as compat
 
 
-VALID_QUERY_PREDICATES = {
-    None,
-    "intersects",
-    "within",
-    "contains",
-    "overlaps",
-    "crosses",
-    "touches",
-}
-
-
 def _get_sindex_class():
     """Dynamically chooses a spatial indexing backend.
 
@@ -63,10 +52,6 @@ if compat.HAS_RTREE:
             Geometries from which to build the spatial index.
         """
 
-        # set of valid predicates for this spatial index
-        # by default, the global set
-        valid_query_predicates = VALID_QUERY_PREDICATES
-
         def __init__(self, geometry):
             stream = (
                 (i, item.bounds, None)
@@ -89,6 +74,27 @@ if compat.HAS_RTREE:
                 [None] * self.geometries.size, dtype=object
             )
 
+        @property
+        def valid_query_predicates(self):
+            """Returns valid predicates for this spatial index.
+
+            Returns
+            -------
+            set
+                Set of valid predicates for this spatial index.
+            """
+            return {
+                None,
+                "intersects",
+                "within",
+                "contains",
+                "overlaps",
+                "crosses",
+                "touches",
+                "covers",
+                "contains_properly",
+            }
+
         def query(self, geometry, predicate=None, sort=False):
             """Compatibility layer for pygeos.query.
 
@@ -173,7 +179,12 @@ if compat.HAS_RTREE:
             elif predicate is not None:
                 # For the remaining predicates,
                 # we compare input_geom.predicate(tree_geom)
-                if predicate in ("contains", "intersects"):
+                if predicate in (
+                    "contains",
+                    "intersects",
+                    "covers",
+                    "contains_properly",
+                ):
                     # prepare this input geometry
                     geometry = prep(geometry)
                 tree_idx = [
@@ -277,10 +288,6 @@ if compat.HAS_PYGEOS:
             Geometries from which to build the spatial index.
         """
 
-        # set of valid predicates for this spatial index
-        # by default, the global set
-        valid_query_predicates = VALID_QUERY_PREDICATES
-
         def __init__(self, geometry):
             # set empty geometries to None to avoid segfault on GEOS <= 3.6
             # see:
@@ -293,6 +300,17 @@ if compat.HAS_PYGEOS:
             # store geometries, including empty geometries for user access
             self.geometries = geometry.copy()
 
+        @property
+        def valid_query_predicates(self):
+            """Returns valid predicates for this spatial index.
+
+            Returns
+            -------
+            set
+                Set of valid predicates for this spatial index.
+            """
+            return pygeos.strtree.VALID_PREDICATES | set([None])
+
         def query(self, geometry, predicate=None, sort=False):
             """Wrapper for pygeos.query.
 


Test patch:
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index a498ae44..a7391967 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -297,6 +297,56 @@ class TestPygeosInterface:
                 box(-0.5, -0.5, 1.5, 1.5),
                 [],
             ),  # bbox intersects but geom does not touch
+            (
+                "contains",
+                box(10, 10, 20, 20),
+                [5],
+            ),  # contains but does not contains_properly
+            (
+                "covers",
+                box(-0.5, -0.5, 1, 1),
+                [0, 1],
+            ),  # covers (0, 0) and (1, 1)
+            (
+                "covers",
+                box(0.001, 0.001, 0.99, 0.99),
+                [],
+            ),  # does not cover any
+            (
+                "covers",
+                box(0, 0, 1, 1),
+                [0, 1],
+            ),  # covers but does not contain
+            (
+                "contains_properly",
+                box(0, 0, 1, 1),
+                [],
+            ),  # intersects but does not contain
+            (
+                "contains_properly",
+                box(0, 0, 1.001, 1.001),
+                [1],
+            ),  # intersects 2 and contains 1
+            (
+                "contains_properly",
+                box(0.5, 0.5, 1.001, 1.001),
+                [1],
+            ),  # intersects 1 and contains 1
+            (
+                "contains_properly",
+                box(0.5, 0.5, 1.5, 1.5),
+                [1],
+            ),  # intersects and contains
+            (
+                "contains_properly",
+                box(-1, -1, 2, 2),
+                [0, 1],
+            ),  # intersects and contains multiple
+            (
+                "contains_properly",
+                box(10, 10, 20, 20),
+                [],
+            ),  # contains but does not contains_properly
         ),
     )
     def test_query(self, predicate, test_geom, expected):
@@ -395,7 +445,11 @@ class TestPygeosInterface:
             ("within", [(0.25, 0.28, 0.75, 0.75)], [[], []]),  # does not intersect
             ("within", [(0, 0, 10, 10)], [[], []]),  # intersects but is not within
             ("within", [(11, 11, 12, 12)], [[0], [5]]),  # intersects and is within
-            ("contains", [(0, 0, 1, 1)], [[], []]),  # intersects but does not contain
+            (
+                "contains",
+                [(0, 0, 1, 1)],
+                [[], []],
+            ),  # intersects and covers, but does not contain
             (
                 "contains",
                 [(0, 0, 1.001, 1.001)],
@@ -412,6 +466,62 @@ class TestPygeosInterface:
                 [(-1, -1, 2, 2)],
                 [[0, 0], [0, 1]],
             ),  # intersects and contains multiple
+            (
+                "contains",
+                [(10, 10, 20, 20)],
+                [[0], [5]],
+            ),  # contains but does not contains_properly
+            ("touches", [(-1, -1, 0, 0)], [[0], [0]]),  # bbox intersects and touches
+            (
+                "touches",
+                [(-0.5, -0.5, 1.5, 1.5)],
+                [[], []],
+            ),  # bbox intersects but geom does not touch
+            (
+                "covers",
+                [(-0.5, -0.5, 1, 1)],
+                [[0, 0], [0, 1]],
+            ),  # covers (0, 0) and (1, 1)
+            (
+                "covers",
+                [(0.001, 0.001, 0.99, 0.99)],
+                [[], []],
+            ),  # does not cover any
+            (
+                "covers",
+                [(0, 0, 1, 1)],
+                [[0, 0], [0, 1]],
+            ),  # covers but does not contain
+            (
+                "contains_properly",
+                [(0, 0, 1, 1)],
+                [[], []],
+            ),  # intersects but does not contain
+            (
+                "contains_properly",
+                [(0, 0, 1.001, 1.001)],
+                [[0], [1]],
+            ),  # intersects 2 and contains 1
+            (
+                "contains_properly",
+                [(0.5, 0.5, 1.001, 1.001)],
+                [[0], [1]],
+            ),  # intersects 1 and contains 1
+            (
+                "contains_properly",
+                [(0.5, 0.5, 1.5, 1.5)],
+                [[0], [1]],
+            ),  # intersects and contains
+            (
+                "contains_properly",
+                [(-1, -1, 2, 2)],
+                [[0, 0], [0, 1]],
+            ),  # intersects and contains multiple
+            (
+                "contains_properly",
+                [(10, 10, 20, 20)],
+                [[], []],
+            ),  # contains but does not contains_properly
         ),
     )
     def test_query_bulk(self, predicate, test_geom, expected):