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