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():