You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/07/25 05:35:13 UTC

(sedona) branch master updated: [GH-3162][GH-3163][GH-3164] Add distributed GeoSeries geometry methods (#3165)

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

jiayuasu 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 9a88736f9df [GH-3162][GH-3163][GH-3164] Add distributed GeoSeries geometry methods (#3165)
9a88736f9df is described below

commit 9a88736f9dfdda632c5756b5a5072df6f214ce57
Author: Jia Yu <ji...@apache.org>
AuthorDate: Fri Jul 24 22:35:07 2026 -0700

    [GH-3162][GH-3163][GH-3164] Add distributed GeoSeries geometry methods (#3165)
---
 python/sedona/spark/geopandas/base.py              |  82 ++++++++++
 python/sedona/spark/geopandas/geoseries.py         |  56 ++++++-
 python/tests/geopandas/test_geoseries.py           | 170 +++++++++++++++++++++
 .../tests/geopandas/test_match_geopandas_series.py |  95 ++++++++++++
 4 files changed, 401 insertions(+), 2 deletions(-)

diff --git a/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py
index 30a60e65dc8..efae3de785d 100644
--- a/python/sedona/spark/geopandas/base.py
+++ b/python/sedona/spark/geopandas/base.py
@@ -632,6 +632,29 @@ class GeoFrame(metaclass=ABCMeta):
         """
         return _delegate_to_geometry_column("has_z", self)
 
+    @property
+    def has_m(self):
+        """Return a ``Series`` of ``dtype('bool')`` with value ``True`` for
+        features that have an m-component.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> s = GeoSeries.from_wkt(
+        ...     [
+        ...         "POINT M (2 3 5)",
+        ...         "POINT Z (1 2 3)",
+        ...         "POINT (0 0)",
+        ...     ]
+        ... )
+        >>> s.has_m
+        0     True
+        1    False
+        2    False
+        dtype: bool
+        """
+        return _delegate_to_geometry_column("has_m", self)
+
     # def get_precision(self):
     #     raise NotImplementedError("This method is not implemented yet.")
 
@@ -919,6 +942,31 @@ class GeoFrame(metaclass=ABCMeta):
             "delaunay_triangles", self, tolerance, only_edges
         )
 
+    def constrained_delaunay_triangles(self):
+        """Return the constrained Delaunay triangulation of each polygon.
+
+        The edges of each input polygon are included in the resulting triangle
+        edges. The result for each row is a ``GeometryCollection`` of polygons.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import Polygon
+        >>> s = GeoSeries([Polygon([(0, 0), (1, 1), (0, 1)])])
+        >>> s.constrained_delaunay_triangles()
+        0    GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 0...
+        dtype: geometry
+
+        See Also
+        --------
+        GeoSeries.delaunay_triangles : unconstrained Delaunay triangulation
+        """
+        return _delegate_to_geometry_column("constrained_delaunay_triangles", self)
+
     def voronoi_polygons(self, tolerance=0.0, extend_to=None, only_edges=False):
         """Return Voronoi diagram of the vertices of each geometry.
 
@@ -1286,6 +1334,40 @@ class GeoFrame(metaclass=ABCMeta):
         """
         return _delegate_to_geometry_column("minimum_clearance", self)
 
+    def minimum_clearance_line(self):
+        """Return linestrings whose endpoints define the minimum clearance.
+
+        A geometry's minimum clearance is the smallest distance by which a
+        vertex could be moved to produce an invalid geometry. If a geometry has
+        no minimum clearance, an empty LineString is returned.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(0, 0), (1, 1), (3, 2)]),
+        ...         Point(0, 0),
+        ...     ]
+        ... )
+        >>> s.minimum_clearance_line()
+        0    LINESTRING (0 1, 0.5 0.5)
+        1        LINESTRING (0 0, 1 1)
+        2             LINESTRING EMPTY
+        dtype: geometry
+
+        See Also
+        --------
+        GeoSeries.minimum_clearance : minimum clearance distance
+        """
+        return _delegate_to_geometry_column("minimum_clearance_line", self)
+
     def normalize(self):
         """Return a ``GeoSeries`` of normalized geometries.
 
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index c7bb57b2acb..9fd4199bdb4 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -1171,11 +1171,37 @@ class GeoSeries(GeoFrame, pspd.Series):
 
     @property
     def has_z(self) -> pspd.Series:
-        spark_expr = stf.ST_HasZ(self.spark.column)
-        return self._query_geometry_column(
+        # ST_HasZ checks the first coordinate. GeoPandas checks every component
+        # of a mixed-dimensional GeometryCollection.
+        spark_expr = F.when(
+            stf.ST_GeometryType(self.spark.column) == "ST_GeometryCollection",
+            F.exists(
+                stf.ST_DumpPoints(self.spark.column),
+                lambda point: stf.ST_HasZ(point),
+            ),
+        ).otherwise(stf.ST_HasZ(self.spark.column))
+        result = self._query_geometry_column(
             spark_expr,
             returns_geom=False,
         )
+        return _to_bool(result)
+
+    @property
+    def has_m(self) -> pspd.Series:
+        # ST_HasM checks the first coordinate. GeoPandas checks every component
+        # of a mixed-dimensional GeometryCollection.
+        spark_expr = F.when(
+            stf.ST_GeometryType(self.spark.column) == "ST_GeometryCollection",
+            F.exists(
+                stf.ST_DumpPoints(self.spark.column),
+                lambda point: stf.ST_HasM(point),
+            ),
+        ).otherwise(stf.ST_HasM(self.spark.column))
+        result = self._query_geometry_column(
+            spark_expr,
+            returns_geom=False,
+        )
+        return _to_bool(result)
 
     def get_precision(self):
         # Implementation of the abstract method.
@@ -1250,6 +1276,22 @@ class GeoSeries(GeoFrame, pspd.Series):
             returns_geom=True,
         )
 
+    def constrained_delaunay_triangles(self) -> "GeoSeries":
+        empty_collection = stc.ST_GeomFromWKT(
+            F.lit("GEOMETRYCOLLECTION EMPTY"),
+            stf.ST_SRID(self.spark.column),
+        )
+        # JTS cannot triangulate an empty polygon, while GeoPandas returns an
+        # empty GeometryCollection.
+        spark_expr = F.when(
+            stf.ST_IsEmpty(self.spark.column),
+            empty_collection,
+        ).otherwise(stf.ST_TriangulatePolygon(self.spark.column))
+        return self._query_geometry_column(
+            spark_expr,
+            returns_geom=True,
+        )
+
     def voronoi_polygons(self, tolerance=0.0, extend_to=None, only_edges=False):
         if only_edges:
             raise NotImplementedError(
@@ -1356,6 +1398,16 @@ class GeoSeries(GeoFrame, pspd.Series):
         ).otherwise(spark_col)
         return self._query_geometry_column(spark_expr, returns_geom=False)
 
+    def minimum_clearance_line(self) -> "GeoSeries":
+        spark_expr = stf.ST_SetSRID(
+            stf.ST_MinimumClearanceLine(self.spark.column),
+            stf.ST_SRID(self.spark.column),
+        )
+        return self._query_geometry_column(
+            spark_expr,
+            returns_geom=True,
+        )
+
     def normalize(self):
         spark_expr = stf.ST_Normalize(self.spark.column)
         return self._query_geometry_column(
diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py
index 3963755d6dc..fe8a246efcb 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -1425,6 +1425,51 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result = s.to_geoframe().has_z
         self.check_pd_series_equal(df_result, expected)
 
+        mixed = GeoSeries.from_wkt(
+            [
+                "GEOMETRYCOLLECTION (POINT (0 0), POINT Z (1 1 2))",
+                (
+                    "GEOMETRYCOLLECTION (POINT (0 0), "
+                    "GEOMETRYCOLLECTION (POINT Z (1 1 2)))"
+                ),
+                "POINT EMPTY",
+                None,
+            ]
+        )
+        mixed_expected = pd.Series([True, True, False, False])
+        self.check_pd_series_equal(mixed.has_z, mixed_expected)
+
+    def test_has_m(self):
+        s = GeoSeries.from_wkt(
+            [
+                "POINT (0 1)",
+                "POINT Z (0 1 2)",
+                "POINT M (0 1 2)",
+                "POINT ZM (0 1 2 3)",
+                "GEOMETRYCOLLECTION (POINT (0 0), POINT M (1 1 2))",
+                "POINT EMPTY",
+                None,
+            ]
+        )
+        expected = pd.Series([False, False, True, True, True, False, False])
+
+        result = s.has_m
+        self.check_pd_series_equal(result, expected)
+
+        # Check that GeoDataFrame works too.
+        df_result = s.to_geoframe().has_m
+        self.check_pd_series_equal(df_result, expected)
+
+        indexed = GeoSeries(
+            [Point(0, 0), None],
+            index=pd.Index(["point", "null"], name="feature_id"),
+        )
+        indexed_expected = pd.Series(
+            [False, False],
+            index=pd.Index(["point", "null"], name="feature_id"),
+        )
+        self.check_pd_series_equal(indexed.has_m, indexed_expected)
+
     def test_get_precision(self):
         pass
 
@@ -1650,6 +1695,62 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result_gpd = df_result.to_geopandas()
         assert len(df_result_gpd) == 2
 
+    def test_constrained_delaunay_triangles(self):
+        triangle = Polygon([(0, 0), (2, 0), (0, 2), (0, 0)])
+        polygon_with_hole = Polygon(
+            [(0, 0), (4, 0), (4, 4), (0, 4), (0, 0)],
+            [[(1, 1), (3, 1), (3, 3), (1, 3), (1, 1)]],
+        )
+        multipolygon = MultiPolygon(
+            [
+                Polygon([(0, 0), (1, 0), (0, 1), (0, 0)]),
+                Polygon([(3, 0), (4, 0), (3, 1), (3, 0)]),
+            ]
+        )
+        geoms = [
+            triangle,
+            polygon_with_hole,
+            multipolygon,
+            Point(0, 0),
+            Polygon(),
+            None,
+        ]
+        index = pd.Index(
+            ["triangle", "hole", "multi", "point", "empty", "null"],
+            name="feature_id",
+        )
+        source = GeoSeries(geoms, index=index, crs="EPSG:3857")
+
+        result = source.constrained_delaunay_triangles()
+
+        actual = result.to_geopandas()
+        assert actual.index.equals(index)
+        for label, original in [
+            ("triangle", triangle),
+            ("hole", polygon_with_hole),
+            ("multi", multipolygon),
+        ]:
+            triangles = actual.loc[label]
+            assert triangles.geom_type == "GeometryCollection"
+            assert shapely.union_all(list(triangles.geoms)).equals(original)
+
+        assert actual.loc["point"].geom_type == "GeometryCollection"
+        assert actual.loc["point"].is_empty
+        assert actual.loc["empty"].geom_type == "GeometryCollection"
+        assert actual.loc["empty"].is_empty
+        assert actual.loc["null"] is None
+        assert result.crs == source.crs
+
+        srids = result._internal.spark_frame.select(
+            stf.ST_SRID(result.spark.column).alias("srid")
+        ).collect()
+        assert {row.srid for row in srids if row.srid is not None} == {3857}
+
+        # Check that GeoDataFrame works too.
+        frame_result = source.to_geoframe().constrained_delaunay_triangles()
+        self.check_sgpd_equals_gpd(frame_result, actual)
+        assert frame_result.crs == source.crs
+
     def test_voronoi_polygons(self):
         s = GeoSeries(
             [
@@ -1923,6 +2024,75 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result = gdf.minimum_clearance()
         self.check_pd_series_equal(df_result, expected)
 
+    def test_minimum_clearance_line(self):
+        geoms = [
+            Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
+            LineString([(0, 0), (1, 1), (3, 2)]),
+            MultiPoint([(0, 0), (3, 4)]),
+            MultiPoint([(1, 1), (1, 1)]),
+            Point(0, 0),
+            Point(),
+            LineString(),
+            Polygon(),
+            None,
+        ]
+        index = pd.Index(
+            [
+                "polygon",
+                "line",
+                "multipoint",
+                "duplicate",
+                "point",
+                "empty-point",
+                "empty-line",
+                "empty-polygon",
+                "null",
+            ],
+            name="feature_id",
+        )
+        source = GeoSeries(geoms, index=index, crs="EPSG:3857")
+        expected = gpd.GeoSeries(
+            [
+                LineString([(0, 1), (0.5, 0.5)]),
+                LineString([(0, 0), (1, 1)]),
+                LineString([(3, 4), (0, 0)]),
+                LineString(),
+                LineString(),
+                LineString(),
+                LineString(),
+                LineString(),
+                None,
+            ],
+            index=index,
+            crs="EPSG:3857",
+        )
+
+        result = source.minimum_clearance_line()
+
+        self.check_sgpd_equals_gpd(result, expected)
+        assert result.crs == source.crs
+        actual = result.to_geopandas()
+        for label in [
+            "duplicate",
+            "point",
+            "empty-point",
+            "empty-line",
+            "empty-polygon",
+        ]:
+            assert actual.loc[label].geom_type == "LineString"
+            assert actual.loc[label].is_empty
+        assert actual.loc["null"] is None
+
+        srids = result._internal.spark_frame.select(
+            stf.ST_SRID(result.spark.column).alias("srid")
+        ).collect()
+        assert {row.srid for row in srids if row.srid is not None} == {3857}
+
+        # Check that GeoDataFrame works too.
+        frame_result = source.to_geoframe().minimum_clearance_line()
+        self.check_sgpd_equals_gpd(frame_result, expected)
+        assert frame_result.crs == source.crs
+
     def test_normalize(self):
         s = GeoSeries(
             [
diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py
index 39865a637c8..b9f8c2aa0ef 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -784,6 +784,42 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             gpd_result = gpd.GeoSeries(geom).has_z
             self.check_pd_series_equal(sgpd_result, gpd_result)
 
+        # Shapely 2.1 added mixed-dimension GeometryCollection support to has_z.
+        if parse_version(shapely.__version__) >= parse_version("2.1.0"):
+            wkts = [
+                "GEOMETRYCOLLECTION (POINT (0 0), POINT Z (1 1 2))",
+                (
+                    "GEOMETRYCOLLECTION (POINT (0 0), "
+                    "GEOMETRYCOLLECTION (POINT Z (1 1 2)))"
+                ),
+                "POINT EMPTY",
+                None,
+            ]
+            sgpd_result = GeoSeries.from_wkt(wkts).has_z
+            gpd_result = gpd.GeoSeries.from_wkt(wkts).has_z
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
+    def test_has_m(self):
+        if parse_version(gpd.__version__) < parse_version("1.1.0"):
+            pytest.skip("geopandas has_m requires version 1.1.0 or higher")
+        if parse_version(shapely.__version__) < parse_version("2.1.0"):
+            pytest.skip("geopandas has_m requires shapely 2.1.0 or higher")
+        if shapely.geos_version < (3, 12, 0):
+            pytest.skip("has_m requires GEOS 3.12.0 or higher")
+
+        wkts = [
+            "POINT (0 1)",
+            "POINT Z (0 1 2)",
+            "POINT M (0 1 2)",
+            "POINT ZM (0 1 2 3)",
+            "GEOMETRYCOLLECTION (POINT (0 0), POINT M (1 1 2))",
+            "POINT EMPTY",
+            None,
+        ]
+        sgpd_result = GeoSeries.from_wkt(wkts).has_m
+        gpd_result = gpd.GeoSeries.from_wkt(wkts).has_m
+        self.check_pd_series_equal(sgpd_result, gpd_result)
+
     def test_get_precision(self):
         pass
 
@@ -886,6 +922,39 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             result = GeoSeries(geom).delaunay_triangles()
             assert len(result) == len(geom)
 
+    def test_constrained_delaunay_triangles(self):
+        if parse_version(gpd.__version__) < parse_version("1.1.0"):
+            pytest.skip(
+                "geopandas constrained_delaunay_triangles requires version 1.1.0 or higher"
+            )
+        if parse_version(shapely.__version__) < parse_version("2.1.0"):
+            pytest.skip(
+                "geopandas constrained_delaunay_triangles requires shapely 2.1.0 or higher"
+            )
+        if shapely.geos_version < (3, 10, 0):
+            pytest.skip("constrained_delaunay_triangles requires GEOS 3.10.0 or higher")
+
+        geoms = [
+            Polygon([(0, 0), (2, 0), (0, 2), (0, 0)]),
+            Polygon(
+                [(0, 0), (4, 0), (4, 4), (0, 4), (0, 0)],
+                [[(1, 1), (3, 1), (3, 3), (1, 3), (1, 1)]],
+            ),
+            MultiPolygon(
+                [
+                    Polygon([(0, 0), (1, 0), (0, 1), (0, 0)]),
+                    Polygon([(3, 0), (4, 0), (3, 1), (3, 0)]),
+                ]
+            ),
+            GeometryCollection([Polygon([(0, 0), (1, 0), (0, 1), (0, 0)])]),
+            Point(0, 0),
+            Polygon(),
+            None,
+        ]
+        sgpd_result = GeoSeries(geoms).constrained_delaunay_triangles()
+        gpd_result = gpd.GeoSeries(geoms).constrained_delaunay_triangles()
+        self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
     def test_voronoi_polygons(self):
         # Sedona ST_VoronoiPolygons is element-wise, while geopandas operates on
         # all points across the GeoSeries as a single set. Cannot compare directly.
@@ -987,6 +1056,32 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             gpd_result = gpd.GeoSeries(geom).minimum_clearance()
             self.check_pd_series_equal(sgpd_result, gpd_result)
 
+    def test_minimum_clearance_line(self):
+        if parse_version(gpd.__version__) < parse_version("1.1.0"):
+            pytest.skip(
+                "geopandas minimum_clearance_line requires version 1.1.0 or higher"
+            )
+        if parse_version(shapely.__version__) < parse_version("2.1.0"):
+            pytest.skip(
+                "geopandas minimum_clearance_line requires shapely 2.1.0 or higher"
+            )
+
+        geoms = [
+            Point(0, 0),
+            LineString([(0, 0), (1, 1), (3, 2)]),
+            MultiPoint([(0, 0), (3, 4)]),
+            MultiPoint([(1, 1), (1, 1)]),
+            Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
+            GeometryCollection([Polygon([(0, 0), (1, 0), (0, 1), (0, 0)])]),
+            Point(),
+            LineString(),
+            Polygon(),
+            None,
+        ]
+        sgpd_result = GeoSeries(geoms).minimum_clearance_line()
+        gpd_result = gpd.GeoSeries(geoms).minimum_clearance_line()
+        self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
     def test_normalize(self):
         for geom in self.geoms:
             sgpd_result = GeoSeries(geom).normalize()