This is an automated email from the ASF dual-hosted git repository.

jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git


The following commit(s) were added to refs/heads/master by this push:
     new df8cb1e048 [GH-2454] : Implement binary predicate `relate`  for 
Geopandas (#2455)
df8cb1e048 is described below

commit df8cb1e0489fc35112df75ea555eebacad9b237a
Author: Gaurav Chaudhari <[email protected]>
AuthorDate: Tue Oct 28 11:00:35 2025 +0530

    [GH-2454] : Implement binary predicate `relate`  for Geopandas (#2455)
---
 python/sedona/spark/geopandas/base.py              | 71 ++++++++++++++++++++
 python/sedona/spark/geopandas/geoseries.py         | 18 +++++
 python/tests/geopandas/test_geoseries.py           | 77 ++++++++++++++++++++++
 .../tests/geopandas/test_match_geopandas_series.py | 13 ++++
 4 files changed, 179 insertions(+)

diff --git a/python/sedona/spark/geopandas/base.py 
b/python/sedona/spark/geopandas/base.py
index 1f5d67f5a5..3bdf70b39e 100644
--- a/python/sedona/spark/geopandas/base.py
+++ b/python/sedona/spark/geopandas/base.py
@@ -2461,6 +2461,77 @@ class GeoFrame(metaclass=ABCMeta):
     def contains_properly(self, other, align=None):
         raise NotImplementedError("This method is not implemented yet.")
 
+    def relate(self, other, align=None):
+        """Returns the DE-9IM matrix string for the relationship between each 
geometry and `other`.
+
+        The DE-9IM (Dimensionally Extended nine-Intersection Model) is a 
topological model
+        that describes the spatial relationship between two geometries. The 
result is a
+        9-character string describing the dimensions of the intersections 
between the
+        interior, boundary, and exterior of the two geometries.
+
+        The operation works on a 1-to-1 row-wise manner.
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The GeoSeries (elementwise) or geometric object to relate to.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices. 
None defaults to True.
+            If False, the order of elements is preserved.
+
+        Returns
+        -------
+        Series (str)
+            A Series of DE-9IM matrix strings.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import Point, LineString, Polygon
+        >>> s = GeoSeries(
+        ...     [
+        ...         Point(0, 0),
+        ...         Point(0, 0),
+        ...         LineString([(0, 0), (1, 1)]),
+        ...     ]
+        ... )
+        >>> s2 = GeoSeries(
+        ...     [
+        ...         Point(0, 0),
+        ...         Point(1, 1),
+        ...         LineString([(0, 0), (1, 1)]),
+        ...     ]
+        ... )
+
+        >>> s.relate(s2)
+        0    0FFFFFFF2
+        1    FF0FFF0F2
+        2    1FFF0FFF2
+        dtype: object
+
+        Notes
+        -----
+        This method works in a row-wise manner. It does not check the 
relationship
+        of an element of one GeoSeries with *all* elements of the other one.
+
+        The DE-9IM string has 9 characters, one for each combination of:
+        - Interior/Boundary/Exterior of the first geometry
+        - Interior/Boundary/Exterior of the second geometry
+
+        Each character can be:
+        - '0': intersection is a point (dimension 0)
+        - '1': intersection is a line (dimension 1)
+        - '2': intersection is an area (dimension 2)
+        - 'F': no intersection (empty set)
+
+        See also
+        --------
+        GeoSeries.contains
+        GeoSeries.intersects
+        GeoSeries.within
+        """
+        return _delegate_to_geometry_column("relate", self, other, align)
+
     def to_parquet(self, path, **kwargs):
         raise NotImplementedError("This method is not implemented yet.")
 
diff --git a/python/sedona/spark/geopandas/geoseries.py 
b/python/sedona/spark/geopandas/geoseries.py
index 5740ce7cc0..ab4b5f5fc8 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -79,6 +79,7 @@ IMPLEMENTATION_STATUS = {
         "is_valid_reason",
         "length",
         "make_valid",
+        "relate",
         "set_crs",
         "to_crs",
         "to_geopandas",
@@ -1458,6 +1459,23 @@ class GeoSeries(GeoFrame, pspd.Series):
         # Implementation of the abstract method.
         raise NotImplementedError("This method is not implemented yet.")
 
+    # 
============================================================================
+    # Binary Predicates
+    # 
============================================================================
+    def relate(self, other, align=None) -> pspd.Series:
+        other, extended = self._make_series_of_val(other)
+        align = False if extended else align
+
+        spark_col = stp.ST_Relate(F.col("L"), F.col("R"))
+        result = self._row_wise_operation(
+            spark_col,
+            other,
+            align,
+            returns_geom=False,
+            default_val=None,
+        )
+        return result
+
     # 
============================================================================
     # SPATIAL PREDICATES
     # 
============================================================================
diff --git a/python/tests/geopandas/test_geoseries.py 
b/python/tests/geopandas/test_geoseries.py
index 42fd011581..66558b6b54 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -1954,6 +1954,83 @@ e": "Feature", "properties": {}, "geometry": {"type": 
"Point", "coordinates": [3
     def test_contains_properly(self):
         pass
 
+    def test_relate(self):
+        s = GeoSeries(
+            [
+                Point(0, 0),
+                Point(0, 0),
+                LineString([(0, 0), (1, 1)]),
+            ]
+        )
+        s2 = GeoSeries(
+            [
+                Point(0, 0),
+                Point(1, 1),
+                LineString([(0, 0), (1, 1)]),
+            ]
+        )
+        # "ABCDEFGHI" DE-9 Format
+        # A Dimension of intersection
+        # B Dimension of interior intersection
+        # C Dimension of boundary intersection
+        # D Interior of first geometry intersects exterior of second
+        # E Exterior of first geometry intersects interior of second
+        # F Boundary of first geometry intersects exterior of second
+        # G Exterior of first geometry intersects boundary of second
+        # H Exterior of first geometry intersects exterior of second
+        # I Dimension of intersection for interiors
+        # 0 = false, 1 = point, 2 = line, F = area
+
+        # 1. Test with single geometry
+        point = Point(0, 0)
+        result = s.relate(point)
+        expected = pd.Series(["0FFFFFFF2", "0FFFFFFF2", "FF10F0FF2"])
+        self.check_pd_series_equal(result, expected)
+
+        result = s.relate(s2)
+        expected = pd.Series(["0FFFFFFF2", "FF0FFF0F2", "1FFF0FFF2"])
+        self.check_pd_series_equal(result, expected)
+        # 2. Test with align=True (different indices)
+        s3 = GeoSeries(
+            [
+                Point(0, 0),
+                Point(1, 1),
+            ],
+            index=range(1, 3),
+        )
+        s4 = GeoSeries(
+            [
+                Point(0, 0),
+                Point(1, 1),
+            ],
+            index=range(0, 2),
+        )
+        result = s3.relate(s4, align=True)
+        expected = pd.Series([None, "FF0FFF0F2", None], index=[0, 1, 2])
+        self.check_pd_series_equal(result, expected)
+
+        # 3. Test with align=False
+        result = s3.relate(s4, align=False)
+        expected = pd.Series(["0FFFFFFF2", "0FFFFFFF2"], index=range(1, 3))
+        self.check_pd_series_equal(result, expected)
+
+        # 4. Check that GeoDataFrame works too
+        df_result = s.to_geoframe().relate(s2, align=False)
+        expected = pd.Series(["0FFFFFFF2", "FF0FFF0F2", "1FFF0FFF2"])
+        self.check_pd_series_equal(df_result, expected)
+
+        # 5. touching_polygons and overlapping polygon case
+        touching_poly_a = Polygon(((0, 0), (1, 0), (1, 1), (0, 1), (0, 0)))
+        touching_poly_b = Polygon(((1, 0), (2, 0), (2, 1), (1, 1), (1, 0)))
+        overlapping_poly_a = Polygon(((0, 0), (2, 0), (2, 2), (0, 2), (0, 0)))
+        overlapping_poly_b = Polygon(((1, 1), (3, 1), (3, 3), (1, 3), (1, 1)))
+        s5 = GeoSeries([touching_poly_a, overlapping_poly_a])
+        s6 = GeoSeries([touching_poly_b, overlapping_poly_b])
+        result = s5.relate(s6)
+
+        expected = pd.Series(["FF2F11212", "212101212"])
+        self.check_pd_series_equal(result, expected)
+
     def test_set_crs(self):
         geo_series = sgpd.GeoSeries([Point(0, 0), Point(1, 1)], 
name="geometry")
         assert geo_series.crs == None
diff --git a/python/tests/geopandas/test_match_geopandas_series.py 
b/python/tests/geopandas/test_match_geopandas_series.py
index e0f713c167..9b79b011a5 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -1073,6 +1073,19 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
     def test_contains_properly(self):
         pass
 
+    def test_relate(self):
+        for geom, geom2 in self.pairs:
+            sgpd_result = GeoSeries(geom).relate(GeoSeries(geom2), align=True)
+            gpd_result = gpd.GeoSeries(geom).relate(gpd.GeoSeries(geom2), 
align=True)
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
+            if len(geom) == len(geom2):
+                sgpd_result = GeoSeries(geom).relate(GeoSeries(geom2), 
align=False)
+                gpd_result = gpd.GeoSeries(geom).relate(
+                    gpd.GeoSeries(geom2), align=False
+                )
+                self.check_pd_series_equal(sgpd_result, gpd_result)
+
     def test_set_crs(self):
         for geom in self.geoms:
             if isinstance(geom[0], Polygon) and geom[0] == Polygon():

Reply via email to