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:
I agree. I do not remember the discussion really.

I'd say the only real requirement to do this is to standardize the error raised. We should also have a standard error for geometry types (eg: a KDTree based spatial index can only handle points, not polygons).

I think we should add standardized errors for both and catch and convert the specific errors within our wrappers.
pygeos also supports `covers` and `covered_by` predicates in addition to `contains_properly` and the set above.  Not sure if these are also supported by rtree implementations here.

I recall the set above was used to define the subset of predicates known to be available in both, and also to provide standardized error handling.

One option might be to use the [enumeration in pygeos strtree](https://github.com/pygeos/pygeos/blob/0.8/pygeos/strtree.py#L9) to check predicates supported by pygeos for `query` / `query_bulk`, since that has to be in sync with the underlying implementation.

Otherwise, pygeos emits a `ValueError` on unsupported predicate, but that would require checking also the error message (since a number of things raise `ValueError`s) - which seems brittle, since that error message may get changed at any point without being aware that things here depend on that value.
Hmm. I feel that from the GeoPandas side we should have:
- A standard error message for unknown predicates.
- A standard error message for unsupported geometries.
- A standard API for querying available predicates.

Now, if for example someone wanted to generate a wrapper for a spatial index that does not have _any_ way to query what predicates it supports (and they aren't listed in the docs or anything), the user could _not_ implement the method in which case a `NotImplementedError` would be raised.

As to how to catch and convert errors from the underlaying spatial index within the wrapper, I think that's an implementation detail. We can get as messy as we want with PyGEOS (since we're familiar with the internals).

This may be better described as part of a PR. I'll try to cook one up within the next couple of days.
Perhas this hard-coded set of predicates could be replaced by a bit of custom code in each sindex backend, which in the case of pygeos could simply obtain that from the enumeration mentioned above. 

Then internally, for all intents and purposes, this could be used similar to how the hard-coded set is used now.

I think the main point is to push the maintenance of the set of available predicates down to the underlying backends, and load it dynamically here.
We can move `VALID_QUERY_PREDICATES` variable from module level to be a class attribute/property? And then it can be different for both implementations?

Also, I don't think there is a specific reason that the rtree-based sindex couldn't also support `covers` and `covered_by`. We are simply using Shapely geometry objects in the query method, and so whather shapely supports, can be supported in the query as well (I think `covered_by` is not yet in a released version though). 
Even `contains_properly` is actually available in Shapely.

Resolving patch:
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -5,17 +5,6 @@
 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 @@ class RTreeIndex(rtree.index.Index):
             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 @@ def __init__(self, geometry):
                 [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 @@ def query(self, geometry, predicate=None, sort=False):
             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 @@ class PyGEOSSTRTreeIndex(pygeos.STRtree):
             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 @@ def __init__(self, geometry):
             # 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
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -297,6 +297,56 @@ def test_intersection_invalid_bounds_tuple(self, test_geom):
                 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 @@ def test_query_sorting(self, sort, expected):
             ("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 @@ def test_query_sorting(self, sort, expected):
                 [(-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):