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/10/12 07:20:05 UTC
(sedona-db) branch main updated: feat(python/sedonadb-geopandas): add explode (#1402)
This is an automated email from the ASF dual-hosted git repository.
jiayuasu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git
The following commit(s) were added to refs/heads/main by this push:
new f0ba98696 feat(python/sedonadb-geopandas): add explode (#1402)
f0ba98696 is described below
commit f0ba98696dbb19fc7c2c2128f6f0f1e6cdf712cf
Author: Jia Yu <ji...@wherobots.com>
AuthorDate: Mon Oct 12 00:19:59 2026 -0700
feat(python/sedonadb-geopandas): add explode (#1402)
---
python/sedonadb-geopandas/README.md | 7 +
.../python/sedonadb_geopandas/_frame.py | 117 ++++++++++++
.../python/sedonadb_geopandas/_series.py | 21 ++
python/sedonadb-geopandas/tests/test_explode.py | 211 +++++++++++++++++++++
4 files changed, 356 insertions(+)
diff --git a/python/sedonadb-geopandas/README.md b/python/sedonadb-geopandas/README.md
index ede9b131a..5c60a53b6 100644
--- a/python/sedonadb-geopandas/README.md
+++ b/python/sedonadb-geopandas/README.md
@@ -38,6 +38,7 @@ web = gdf.to_crs("EPSG:3857") # reproject (CRS tracked through)
joined = gdf.sjoin(regions, predicate="within") # spatial join
zones = joined.dissolve(by="region") # group and union geometry
+parts = zones.explode() # one row per geometry part
result = zones.to_geopandas() # back to a real GeoDataFrame
```
@@ -94,6 +95,12 @@ GeoPandas' `align=False`; there is no index to align on. `touches` inherits
an engine issue with geometry collections that mix dimensions
(apache/sedona-db#1383).
+`explode()` takes parts one level deep and drops rows whose geometry has no
+parts, as GeoPandas does. Without an index it cannot number the parts
+(`index_parts=True`), and it explodes the active geometry column only:
+GeoPandas explodes the active geometry even when `column=` names another
+geometry column, and explodes non-geometry columns as lists.
+
`sjoin()` requires both geometry columns to share a CRS: GeoPandas warns on a
mismatch and joins anyway, which is almost always a mistake, so this raises and
points at `to_crs()`. `on_attribute` is not supported yet.
diff --git a/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py b/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
index 4f682fb24..410cd1e91 100644
--- a/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
+++ b/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
@@ -111,6 +111,85 @@ def _is_missing(value):
return isinstance(value, float) and value != value
+def _explode_parts(df, column):
+ """`df` with one row per part of its geometry column `column`.
+
+ Parts are taken one level deep, as `shapely.get_parts` does. A single
+ geometry is its own one part, even when empty; a multi-geometry or
+ collection with no parts, or a missing geometry, yields no row. The
+ exploded column moves to the end. All three as in GeoPandas.
+
+ `ST_Dump` lists the parts before they are unnested, so each part is
+ stored once rather than the whole geometry being repeated per part. It
+ flattens nested collections, though, so a geometry collection's members
+ are read one level deep with `ST_GeometryN` instead, which repeats the
+ collection (only) once per member while it is exploded.
+ """
+ ctx = df._ctx
+ column_type = df.schema.field(column).type
+ crs = column_type.crs
+ spherical = "SPHERICAL" in str(getattr(column_type, "edge_type", "")).upper()
+ df = df.filter(df[column].is_not_null())
+ geometry = df[column]
+ is_collection = geometry.geo.geometry_type() == lit("ST_GeometryCollection")
+ # nvl2 needs its condition in the same type as its values, hence the
+ # binary flag (non-null on collections only), as in GeoSeries.envelope.
+ collection_flag = _binary_flag(is_collection)
+ carried = collection_flag.funcs.nvl2(geometry.geo.as_binary(), lit(None))
+ members = (
+ geometry.geo.num_geometries().cast(pa.int64()) * is_collection.cast(pa.int64())
+ ).funcs.coalesce(lit(0))
+ # A collection's parts come from its member positions, so its dumped
+ # leaves are dropped before unnesting: unnested in parallel with the
+ # positions, each leaf would carry the whole collection.
+ leaves = geometry.geo.dump()
+ leaves = leaves.funcs.array_slice(
+ lit(1),
+ leaves.funcs.cardinality().cast(pa.int64()) * (~is_collection).cast(pa.int64()),
+ )
+ others = [name for name in df.schema.names if name != column]
+ dump, position, carry = (
+ _unused_name(df, "__dump"),
+ _unused_name(df, "__member"),
+ _unused_name(df, "__collection"),
+ )
+ staged = df.select(
+ *[df[name] for name in others],
+ leaves.alias(dump),
+ ctx.lit(1).funcs.range(members + lit(1)).alias(position),
+ carried.alias(carry),
+ ).unnest(dump, position)
+
+ def from_wkb(wkb):
+ return wkb.funcs.st_geogfromwkb() if spherical else wkb.funcs.st_geomfromwkb()
+
+ member = from_wkb(staged[carry]).geo.geometry_n(staged[position]).geo.as_binary()
+ wkb = _binary_flag(staged[position].is_not_null()).funcs.nvl2(
+ member, staged[dump]["geom"].cast(pa.binary())
+ )
+ # WKB carries neither the spatial kind nor the CRS: both are restored.
+ part = from_wkb(wkb)
+ if crs is not None:
+ part = part.funcs.st_setcrs(ctx.lit(crs.to_json()))
+ elif spherical:
+ # The geography constructor assigns OGC:CRS84; SRID 0 clears it, as
+ # the column had no CRS.
+ part = part.geo.set_srid(ctx.lit(0))
+ return staged.select(*[staged[name] for name in others], part.alias(column))
+
+
+def _binary_flag(condition):
+ """A binary value that is non-null exactly where `condition` is true."""
+ return condition.funcs.nullif(lit(False)).cast(pa.string()).cast(pa.binary())
+
+
+def _unused_name(df, name):
+ """`name`, prefixed with underscores until no column of `df` has it."""
+ while name in df.schema.names:
+ name = f"_{name}"
+ return name
+
+
class GeoDataFrame:
"""A lazy SedonaDB frame in the shape of a `geopandas.GeoDataFrame`.
@@ -806,6 +885,44 @@ class GeoDataFrame:
unioned = collected.mutate(geometry_expr.alias(self._geometry_name))
return GeoDataFrame(unioned, self._geometry_name)
+ def explode(self, column=None, ignore_index=False, index_parts=False):
+ """One row per part of each multi-part geometry, as in GeoPandas.
+
+ Parts are taken one level deep, as `shapely.get_parts` does: a
+ multi-geometry or collection yields its members, a single geometry
+ stays one row, and a row whose geometry has no parts (an empty
+ multi-geometry or collection, or a missing geometry) is dropped. The
+ other columns repeat for each part, and the exploded column moves to
+ the end, as in GeoPandas.
+
+ Args:
+ column: The column to explode: only the active geometry column
+ (the default) is supported. GeoPandas explodes the active
+ geometry even when `column` names another geometry column,
+ and explodes a non-geometry column as lists.
+ ignore_index: Accepted for compatibility; there is no index.
+ index_parts: Not supported, since there is no index to number the
+ parts in.
+
+ Returns:
+ A `GeoDataFrame` with the same active geometry column.
+ """
+ if column is None:
+ if self._geometry_name is None:
+ raise AttributeError("This GeoDataFrame has no active geometry column")
+ column = self._geometry_name
+ if column not in self.columns:
+ raise KeyError(column)
+ if column != self._geometry_name:
+ raise NotImplementedError(
+ f"explode() supports the active geometry column only, not {column!r}"
+ )
+ if index_parts:
+ raise NotImplementedError(
+ "explode() cannot number parts with index_parts=True: there is no index"
+ )
+ return GeoDataFrame(_explode_parts(self._df, column), self._geometry_name)
+
def to_geopandas(self):
"""Execute and return a `geopandas.GeoDataFrame` (or plain DataFrame).
diff --git a/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py b/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py
index d3f91052b..2db2894bd 100644
--- a/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py
+++ b/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py
@@ -773,6 +773,27 @@ class GeoSeries(Series):
"""A point guaranteed to lie on each geometry (`ST_PointOnSurface`)."""
return self._geo(self._expr.geo.point_on_surface())
+ def explode(self, ignore_index=False, index_parts=False):
+ """One element per part of each multi-part geometry, as in GeoPandas.
+
+ Parts are taken one level deep, and a geometry with no parts (or a
+ missing one) yields none; see `GeoDataFrame.explode`. The result has a
+ different number of rows, so it belongs to a new frame and cannot be
+ combined with columns of this one. `ignore_index` is accepted for
+ compatibility; `index_parts=True` is not supported, since there is no
+ index.
+ """
+ from sedonadb_geopandas._frame import _explode_parts
+
+ if index_parts:
+ raise NotImplementedError(
+ "explode() cannot number parts with index_parts=True: there is no index"
+ )
+ exploded = _explode_parts(
+ self._df.select(self._expr.alias(self._name)), self._name
+ )
+ return GeoSeries(exploded, exploded[self._name], self._name)
+
# -- binary operations -----------------------------------------------
def _other(self, other):
diff --git a/python/sedonadb-geopandas/tests/test_explode.py b/python/sedonadb-geopandas/tests/test_explode.py
new file mode 100644
index 000000000..9f72581af
--- /dev/null
+++ b/python/sedonadb-geopandas/tests/test_explode.py
@@ -0,0 +1,211 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Exploding multi-part geometries, compared against GeoPandas."""
+
+import geopandas as gpd
+import pytest
+
+import sedonadb_geopandas as sgpd
+
+# Single, empty, multi-part, nested, Z, and missing geometries: the cases where
+# one-level parts, the engine's part count, and GeoPandas' row dropping differ.
+WKT = [
+ "POINT (1 1)",
+ "POINT EMPTY",
+ "LINESTRING EMPTY",
+ "POLYGON EMPTY",
+ "MULTIPOINT ((0 0), (1 1))",
+ "MULTIPOINT EMPTY",
+ "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))",
+ "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))",
+ "MULTIPOLYGON EMPTY",
+ "GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (2 2))",
+ "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING (0 0, 1 1))",
+ "GEOMETRYCOLLECTION EMPTY",
+ "MULTIPOINT Z ((0 0 1), (1 1 2))",
+ None,
+]
+
+
+def _geometries(series):
+ return list(zip(series.to_wkt(), series.has_z))
+
+
+def test_explode_matches_geopandas():
+ # One row per part, one level deep: a nested collection yields its
+ # members whole. An empty single geometry is its own one part, while a
+ # geometry with no parts, or a missing one, drops its row.
+ expected_source = gpd.GeoDataFrame(
+ {"id": range(len(WKT)), "label": [f"row{i}" for i in range(len(WKT))]},
+ geometry=gpd.GeoSeries.from_wkt(WKT),
+ crs=3857,
+ )
+ ours = sgpd.from_geopandas(expected_source).explode().to_geopandas()
+ expected = expected_source.explode()
+ assert list(ours.columns) == list(expected.columns)
+ assert list(ours["id"]) == list(expected["id"])
+ assert list(ours["label"]) == list(expected["label"])
+ assert _geometries(ours.geometry) == _geometries(expected.geometry)
+ assert ours.crs == expected.crs
+
+
+def test_explode_moves_the_geometry_column_last():
+ source = gpd.GeoDataFrame(
+ {"a": [1], "b": [2]},
+ geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"]),
+ )[["geometry", "a", "b"]].rename_geometry("pt")
+ ours = sgpd.from_geopandas(source).explode()
+ expected = source.explode()
+ assert ours.columns == list(expected.columns) == ["a", "b", "pt"]
+ assert ours._geometry_name == expected.geometry.name == "pt"
+ assert len(ours.to_geopandas()) == 2
+
+
+def test_explode_repeats_other_geometry_columns():
+ source = gpd.GeoDataFrame(
+ {"a": [1]}, geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"])
+ )
+ source["other"] = gpd.GeoSeries.from_wkt(["POINT (5 5)"], crs=4326)
+ ours = sgpd.from_geopandas(source).explode().to_geopandas()
+ expected = source.explode()
+ assert list(ours.columns) == list(expected.columns)
+ assert _geometries(ours["other"]) == _geometries(expected["other"])
+ assert ours["other"].crs == expected["other"].crs
+
+
+def test_explode_keeps_geography():
+ # A multi-geometry and a nested collection take different routes to their
+ # parts; both keep the geography type and CRS.
+ gdf = sgpd.GeoDataFrame(
+ sgpd.default_context().sql(
+ "SELECT id, ST_GeogFromWKT(w) AS g FROM (VALUES "
+ "(1, 'MULTIPOINT ((0 0), (1 1))'), "
+ "(2, 'GEOMETRYCOLLECTION (MULTIPOINT ((5 5), (6 6)), POINT (7 7))')"
+ ") AS t(id, w)"
+ ),
+ geometry="g",
+ )
+ exploded = gdf.explode()
+ assert "geography" in str(exploded._df.schema.field("g").type)
+ assert exploded.crs == gdf.crs
+ result = exploded.to_geopandas().sort_values("id", kind="stable")
+ assert [g.wkt for g in result["g"]] == [
+ "POINT (0 0)",
+ "POINT (1 1)",
+ "MULTIPOINT ((5 5), (6 6))",
+ "POINT (7 7)",
+ ]
+
+
+def test_explode_keeps_geography_without_a_crs():
+ # Parts are rebuilt as geography, whose constructor assigns OGC:CRS84; a
+ # column without a CRS stays without one.
+ gdf = sgpd.GeoDataFrame(
+ sgpd.default_context().sql(
+ "SELECT 1 AS id, ST_SetSRID(ST_GeogFromWKT("
+ "'GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (2 2))'), 0) AS g "
+ "UNION ALL SELECT 2, ST_SetSRID(ST_GeogFromWKT('MULTIPOINT ((5 5), (6 6))'), 0)"
+ ),
+ geometry="g",
+ )
+ assert gdf.crs is None
+ exploded = gdf.explode()
+ assert exploded.crs is None
+ assert "geography" in str(exploded._df.schema.field("g").type)
+ assert len(exploded.to_geopandas()) == 4
+
+
+def test_explode_of_an_empty_frame():
+ source = gpd.GeoDataFrame(
+ {"a": [1]}, geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"])
+ )
+ gdf = sgpd.from_geopandas(source)
+ exploded = gdf[gdf["a"] > 5].explode()
+ assert exploded.columns == ["a", "geometry"]
+ assert len(exploded.to_geopandas()) == 0
+
+
+def test_explode_with_columns_named_like_its_working_columns():
+ source = gpd.GeoDataFrame(
+ {"__dump": ["a", "b"], "__member": [1, 2], "__collection": [True, False]},
+ geometry=gpd.GeoSeries.from_wkt(
+ [
+ "MULTIPOINT ((0 0), (1 1))",
+ "GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (2 2))",
+ ]
+ ),
+ )
+ ours = sgpd.from_geopandas(source).explode().to_geopandas()
+ expected = source.explode()
+ assert list(ours.columns) == list(expected.columns)
+ assert list(ours["__dump"]) == list(expected["__dump"])
+ assert _geometries(ours.geometry) == _geometries(expected.geometry)
+
+
+def test_geoseries_explode_matches_geopandas():
+ source = gpd.GeoSeries.from_wkt(WKT, crs=3857, name="geom")
+ gdf = sgpd.from_geopandas(gpd.GeoDataFrame(source.to_frame(), geometry="geom"))
+ ours = gdf.geometry.explode().to_geopandas()
+ expected = source.explode()
+ assert ours.name == expected.name == "geom"
+ assert _geometries(ours) == _geometries(expected)
+ assert ours.crs == expected.crs
+
+
+def test_geoseries_explode_of_a_derived_series():
+ source = gpd.GeoDataFrame(
+ geometry=gpd.GeoSeries.from_wkt(
+ [
+ "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))",
+ "MULTIPOINT ((0 0), (1 1))",
+ ]
+ )
+ )
+ ours = sgpd.from_geopandas(source).geometry.boundary.explode().to_geopandas()
+ expected = source.geometry.boundary.explode()
+ assert _geometries(ours) == _geometries(expected)
+
+
+def test_geoseries_explode_belongs_to_a_new_frame():
+ # The exploded column has more rows than the frame it came from.
+ gdf = sgpd.from_geopandas(
+ gpd.GeoDataFrame(geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"]))
+ )
+ with pytest.raises(ValueError):
+ gdf["parts"] = gdf.geometry.explode()
+
+
+def test_explode_validation():
+ source = gpd.GeoDataFrame(
+ {"v": [1]}, geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"])
+ )
+ source["other"] = gpd.GeoSeries.from_wkt(["POINT (5 5)"])
+ gdf = sgpd.from_geopandas(source)
+ # There is no index, so ignore_index changes nothing.
+ assert len(gdf.explode(ignore_index=True).to_geopandas()) == 2
+ with pytest.raises(NotImplementedError, match="index_parts"):
+ gdf.explode(index_parts=True)
+ with pytest.raises(NotImplementedError, match="index_parts"):
+ gdf.geometry.explode(index_parts=True)
+ with pytest.raises(NotImplementedError, match="active geometry"):
+ gdf.explode(column="v")
+ with pytest.raises(NotImplementedError, match="active geometry"):
+ gdf.explode(column="other")
+ with pytest.raises(KeyError):
+ gdf.explode(column="missing")
+ with pytest.raises(AttributeError, match="no active geometry"):
+ gdf[["v"]].explode()