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/08/07 17:53:41 UTC
(sedona) 01/01: [GH-3243] GeoPandas: Implement GeoDataFrame explode
This is an automated email from the ASF dual-hosted git repository.
jiayuasu pushed a commit to branch feature/geopandas-frame-explode
in repository https://gitbox.apache.org/repos/asf/sedona.git
commit a64b0779a1a349205807aa70c3da94a9e2df26e8
Author: Jia Yu <ji...@apache.org>
AuthorDate: Fri Aug 7 00:05:44 2026 -0700
[GH-3243] GeoPandas: Implement GeoDataFrame explode
---
docs/tutorial/geopandas-api.md | 6 +
docs/tutorial/geopandas-api.zh.md | 5 +
python/sedona/spark/geopandas/_explode.py | 162 +++++++++++++++++++++
python/sedona/spark/geopandas/geodataframe.py | 101 +++++++++++++
python/sedona/spark/geopandas/geoseries.py | 95 ++----------
python/tests/geopandas/test_geodataframe.py | 131 +++++++++++++++++
.../geopandas/test_match_geopandas_dataframe.py | 53 +++++++
7 files changed, 472 insertions(+), 81 deletions(-)
diff --git a/docs/tutorial/geopandas-api.md b/docs/tutorial/geopandas-api.md
index 2fbeff1894a..7b834214b15 100644
--- a/docs/tutorial/geopandas-api.md
+++ b/docs/tutorial/geopandas-api.md
@@ -339,10 +339,16 @@ The GeoPandas API for Apache Sedona implements the most commonly used GeoSeries
- `make_valid()` - Geometry validation and repair
- `sample_points()` - Sample polygons by area and lines by length with native
distributed expressions
+- `GeoDataFrame.explode()` - Expand multipart geometries into rows while
+ retaining their attribute columns and index metadata
- `cx` - Coordinate-based spatial filtering
- `clip()` - Distributed geometry clipping
- `sindex` - Spatial indexing (limited functionality)
+`GeoDataFrame.explode()` uses Sedona's native `ST_Dump` expression with
+Spark `posexplode`. Geometry parts and their attributes remain distributed;
+the operation does not collect source rows or use a Python UDF.
+
### Distributed Geometry Aggregation
- `GeoDataFrame.dissolve()` - Group rows and union each group's geometries
diff --git a/docs/tutorial/geopandas-api.zh.md b/docs/tutorial/geopandas-api.zh.md
index d235288bade..cb2c535e11b 100644
--- a/docs/tutorial/geopandas-api.zh.md
+++ b/docs/tutorial/geopandas-api.zh.md
@@ -338,10 +338,15 @@ Apache Sedona 的 GeoPandas API 已实现最常用的 GeoSeries 与 GeoDataFrame
- `intersection()` —— 几何相交
- `make_valid()` —— 几何校验与修复
- `sample_points()` —— 使用原生分布式表达式按面积对多边形采样、按长度对线采样
+- `GeoDataFrame.explode()` —— 将多部件几何展开为多行,并保留对应的属性列与索引元数据
- `cx` —— 基于坐标范围的空间筛选
- `clip()` —— 分布式几何裁剪
- `sindex` —— 空间索引(功能有限)
+`GeoDataFrame.explode()` 使用 Sedona 原生的 `ST_Dump` 表达式与 Spark
+`posexplode`。几何部件及其属性始终保持分布式处理;该操作不会收集源数据行,
+也不会使用 Python UDF。
+
### 分布式几何聚合
- `GeoDataFrame.dissolve()` —— 对行进行分组,并使用 Sedona 原生分布式聚合
diff --git a/python/sedona/spark/geopandas/_explode.py b/python/sedona/spark/geopandas/_explode.py
new file mode 100644
index 00000000000..46c17a7d34f
--- /dev/null
+++ b/python/sedona/spark/geopandas/_explode.py
@@ -0,0 +1,162 @@
+# 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.
+
+from __future__ import annotations
+
+import typing
+
+from pyspark.pandas.internal import (
+ InternalFrame,
+ NATURAL_ORDER_COLUMN_NAME,
+ SPARK_DEFAULT_INDEX_NAME,
+ SPARK_INDEX_NAME_FORMAT,
+)
+from pyspark.pandas.utils import scol_for, verify_temp_column_name
+from pyspark.sql import functions as F
+
+
+def expand_geometry_column(
+ source_internal: InternalFrame,
+ geometry_position: int,
+ array_builder,
+ ignore_index: bool,
+ index_parts: bool,
+ temp_prefix: str,
+) -> InternalFrame:
+ """Expand one geometry column and carry the complete frame alongside it."""
+
+ internal = source_internal.resolved_copy
+ source_sdf = internal.spark_frame
+ reserved_names = set(source_sdf.columns)
+
+ def temp_column_name(base: str) -> str:
+ suffix = 0
+ candidate = f"__{temp_prefix}_{base}__"
+ while candidate in reserved_names:
+ suffix += 1
+ candidate = f"__{temp_prefix}_{base}_{suffix}__"
+ reserved_names.add(candidate)
+ return typing.cast(str, verify_temp_column_name(source_sdf, candidate))
+
+ index_column_names = [
+ temp_column_name(f"index_{level}")
+ for level in range(len(internal.index_spark_columns))
+ ]
+ data_column_names = [
+ temp_column_name(f"data_{position}")
+ for position in range(len(internal.data_spark_columns))
+ ]
+ parent_order_col = temp_column_name("parent_order")
+ position_col = temp_column_name("position")
+ value_col = temp_column_name("value")
+ sequence_col = temp_column_name("sequence")
+
+ retained_data_columns = [
+ column.alias(name, metadata=field.metadata)
+ for position, (column, name, field) in enumerate(
+ zip(
+ internal.data_spark_columns,
+ data_column_names,
+ internal.data_fields,
+ )
+ )
+ if position != geometry_position
+ ]
+ expanded_sdf = source_sdf.select(
+ *[
+ column.alias(name, metadata=field.metadata)
+ for column, name, field in zip(
+ internal.index_spark_columns,
+ index_column_names,
+ internal.index_fields,
+ )
+ ],
+ *retained_data_columns,
+ scol_for(source_sdf, NATURAL_ORDER_COLUMN_NAME).alias(parent_order_col),
+ F.posexplode(
+ array_builder(internal.data_spark_columns[geometry_position])
+ ).alias(position_col, value_col),
+ ).orderBy(parent_order_col, position_col)
+
+ # The distributed sequence supplies both ignore_index and a stable
+ # natural-order column without a single-partition row-number window.
+ expanded_sdf = InternalFrame.attach_distributed_sequence_column(
+ expanded_sdf, sequence_col
+ )
+
+ if ignore_index:
+ output_index_names = [SPARK_DEFAULT_INDEX_NAME]
+ index_names = [None]
+ index_fields = None
+ index_expressions = [
+ scol_for(expanded_sdf, sequence_col).alias(SPARK_DEFAULT_INDEX_NAME)
+ ]
+ else:
+ output_index_names = list(index_column_names)
+ index_names = list(internal.index_names)
+ index_fields = [
+ field.copy(name=name)
+ for field, name in zip(internal.index_fields, output_index_names)
+ ]
+ index_expressions = [
+ scol_for(expanded_sdf, name) for name in output_index_names
+ ]
+
+ if index_parts:
+ part_index_col = SPARK_INDEX_NAME_FORMAT(len(output_index_names))
+ output_index_names.append(part_index_col)
+ index_names.append(None)
+ index_fields.append(None)
+ index_expressions.append(
+ scol_for(expanded_sdf, position_col).cast("long").alias(part_index_col)
+ )
+
+ data_expressions = []
+ for position, (name, field) in enumerate(
+ zip(data_column_names, internal.data_fields)
+ ):
+ source_name = value_col if position == geometry_position else name
+ data_expressions.append(
+ scol_for(expanded_sdf, source_name).alias(
+ name,
+ metadata=field.metadata,
+ )
+ )
+
+ output_sdf = expanded_sdf.select(
+ *index_expressions,
+ *data_expressions,
+ scol_for(expanded_sdf, sequence_col).alias(NATURAL_ORDER_COLUMN_NAME),
+ )
+ output_schema = output_sdf.schema
+ data_fields = [
+ field.copy(
+ name=name,
+ spark_type=output_schema[name].dataType,
+ nullable=output_schema[name].nullable,
+ )
+ for name, field in zip(data_column_names, internal.data_fields)
+ ]
+
+ return internal.copy(
+ spark_frame=output_sdf,
+ index_spark_columns=[scol_for(output_sdf, name) for name in output_index_names],
+ index_names=index_names,
+ index_fields=index_fields,
+ data_spark_columns=[scol_for(output_sdf, name) for name in data_column_names],
+ data_fields=data_fields,
+ )
diff --git a/python/sedona/spark/geopandas/geodataframe.py b/python/sedona/spark/geopandas/geodataframe.py
index 23124c95c0b..47dea89602e 100644
--- a/python/sedona/spark/geopandas/geodataframe.py
+++ b/python/sedona/spark/geopandas/geodataframe.py
@@ -55,6 +55,7 @@ from pyspark.sql.types import (
from pyspark.pandas.utils import log_advice
from sedona.spark.geopandas._crs import copy_crs_metadata, with_crs_metadata
+from sedona.spark.geopandas._explode import expand_geometry_column
from sedona.spark.geopandas._typing import Label
from sedona.spark.geopandas.base import GeoFrame
from sedona.spark.sql import st_aggregates as sta
@@ -1951,6 +1952,106 @@ class GeoDataFrame(GeoFrame, pspd.DataFrame):
"""
return self.to_geopandas().plot(*args, **kwargs)
+ def explode(
+ self,
+ column=None,
+ ignore_index: bool = False,
+ index_parts: bool = False,
+ **kwargs,
+ ) -> GeoDataFrame:
+ """Explode multi-part geometries into separate rows.
+
+ Attribute columns remain alongside every geometry part. The operation
+ uses Sedona's native ``ST_Dump`` expression and remains distributed.
+
+ Parameters
+ ----------
+ column : column label, default None
+ Column to explode. ``None`` selects the active geometry column. As
+ in GeoPandas, selecting any geometry-typed column explodes the
+ active geometry column; selecting a non-geometry column delegates
+ to pandas-on-Spark.
+ ignore_index : bool, default False
+ If True, label the result index 0, 1, ..., n - 1 and ignore
+ ``index_parts``.
+ index_parts : bool, default False
+ If True, append a zero-based index level identifying each geometry
+ produced from an input row.
+ **kwargs
+ Additional keyword arguments are forwarded when exploding a
+ non-geometry column.
+
+ Returns
+ -------
+ GeoDataFrame
+ A distributed GeoDataFrame with one row per geometry part.
+
+ Examples
+ --------
+ >>> from sedona.spark.geopandas import GeoDataFrame
+ >>> from shapely.geometry import MultiPoint
+ >>> gdf = GeoDataFrame(
+ ... {
+ ... "name": ["a", "b"],
+ ... "geometry": [
+ ... MultiPoint([(0, 0), (1, 1)]),
+ ... MultiPoint([(2, 2), (3, 3)]),
+ ... ],
+ ... }
+ ... )
+ >>> gdf.explode(index_parts=True)
+ name geometry
+ 0 0 a POINT (0 0)
+ 1 a POINT (1 1)
+ 1 0 b POINT (2 2)
+ 1 b POINT (3 3)
+ """
+ if column is None:
+ column = self.geometry.name
+
+ selected = self[column]
+ if not isinstance(selected, sgpd.GeoSeries):
+ exploded = super().explode(
+ column,
+ ignore_index=ignore_index,
+ **kwargs,
+ )
+ return _as_geodataframe_with_geometry(
+ exploded,
+ self._geometry_column_name,
+ )
+
+ # GeoPandas explodes the active geometry even when ``column`` names a
+ # different geometry-typed column. Preserve that compatibility quirk.
+ geometry_label = self.geometry._column_label
+ geometry_position = self._internal.column_labels.index(geometry_label)
+ result_internal = expand_geometry_column(
+ self._internal,
+ geometry_position=geometry_position,
+ array_builder=stf.ST_Dump,
+ ignore_index=ignore_index,
+ index_parts=index_parts,
+ temp_prefix="frame_explode",
+ )
+ # GeoPandas rebuilds a geometry explode by dropping the active column
+ # and adding it back after the attributes, so keep that output order.
+ data_order = [
+ position
+ for position in range(len(result_internal.column_labels))
+ if position != geometry_position
+ ] + [geometry_position]
+ result_internal = result_internal.copy(
+ column_labels=[result_internal.column_labels[i] for i in data_order],
+ data_spark_columns=[
+ result_internal.data_spark_columns[i] for i in data_order
+ ],
+ data_fields=[result_internal.data_fields[i] for i in data_order],
+ )
+ return _as_geodataframe_with_geometry(
+ PandasOnSparkDataFrame(result_internal),
+ self._geometry_column_name,
+ )
+
def dissolve(
self,
by=None,
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index 9afc2cf5333..458c9deee0d 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -31,7 +31,7 @@ from pyspark.pandas import Series as PandasOnSparkSeries
from pyspark.pandas.frame import DataFrame as PandasOnSparkDataFrame
from pyspark.pandas.internal import InternalField, InternalFrame
from pyspark.pandas.series import first_series
-from pyspark.pandas.utils import same_anchor, scol_for, verify_temp_column_name
+from pyspark.pandas.utils import same_anchor, scol_for
from pyspark.sql.types import (
BooleanType,
DoubleType,
@@ -61,6 +61,7 @@ from sedona.spark.geopandas._crs import (
read_crs_metadata,
with_crs_metadata,
)
+from sedona.spark.geopandas._explode import expand_geometry_column
from sedona.spark.geopandas._typing import Label
from sedona.spark.geopandas.base import GeoFrame
from sedona.spark.geopandas.geodataframe import GeoDataFrame
@@ -69,7 +70,6 @@ from packaging.version import parse as parse_version
from pyspark.pandas.internal import (
SPARK_DEFAULT_INDEX_NAME, # __index_level_0__
- SPARK_INDEX_NAME_FORMAT,
NATURAL_ORDER_COLUMN_NAME,
SPARK_DEFAULT_SERIES_NAME, # '0'
)
@@ -997,88 +997,21 @@ class GeoSeries(GeoFrame, pspd.Series):
temp_prefix: str,
):
"""Expand a geometry-array expression while preserving index metadata."""
- internal = self._internal.resolved_copy
- source_sdf = internal.spark_frame
- reserved_names = set(source_sdf.columns)
-
- def temp_column_name(base: str) -> str:
- suffix = 0
- candidate = f"__{temp_prefix}_{base}__"
- while candidate in reserved_names:
- suffix += 1
- candidate = f"__{temp_prefix}_{base}_{suffix}__"
- reserved_names.add(candidate)
- return typing.cast(str, verify_temp_column_name(source_sdf, candidate))
-
- index_column_names = [
- temp_column_name(f"index_{level}")
- for level in range(len(internal.index_spark_columns))
- ]
- parent_order_col = temp_column_name("parent_order")
- position_col = temp_column_name("position")
- value_col = temp_column_name("value")
- sequence_col = temp_column_name("sequence")
-
- expanded_sdf = source_sdf.select(
- *[
- column.alias(name)
- for column, name in zip(
- internal.index_spark_columns, index_column_names
- )
- ],
- scol_for(source_sdf, NATURAL_ORDER_COLUMN_NAME).alias(parent_order_col),
- F.posexplode(array_builder(internal.data_spark_columns[0])).alias(
- position_col, value_col
- ),
- ).orderBy(parent_order_col, position_col)
-
- # The distributed sequence supplies both ignore_index and a stable
- # natural-order column without a single-partition row-number window.
- expanded_sdf = InternalFrame.attach_distributed_sequence_column(
- expanded_sdf, sequence_col
- )
-
- if ignore_index:
- output_index_cols = [SPARK_DEFAULT_INDEX_NAME]
- index_names = [None]
- index_fields = None
- index_expressions = [
- scol_for(expanded_sdf, sequence_col).alias(SPARK_DEFAULT_INDEX_NAME)
- ]
- else:
- output_index_cols = list(index_column_names)
- index_names = list(internal.index_names)
- index_fields = [
- field.copy(name=name)
- for field, name in zip(internal.index_fields, output_index_cols)
- ]
- index_expressions = [
- scol_for(expanded_sdf, name) for name in output_index_cols
- ]
-
- if index_parts:
- part_index_col = SPARK_INDEX_NAME_FORMAT(len(output_index_cols))
- output_index_cols.append(part_index_col)
- index_names.append(None)
- index_fields.append(None)
- index_expressions.append(
- scol_for(expanded_sdf, position_col)
- .cast("long")
- .alias(part_index_col)
- )
-
- output_sdf = expanded_sdf.select(
- *index_expressions,
- scol_for(expanded_sdf, value_col),
- scol_for(expanded_sdf, sequence_col).alias(NATURAL_ORDER_COLUMN_NAME),
+ internal = expand_geometry_column(
+ self._internal,
+ geometry_position=0,
+ array_builder=array_builder,
+ ignore_index=ignore_index,
+ index_parts=index_parts,
+ temp_prefix=temp_prefix,
)
return (
internal,
- output_sdf,
- output_index_cols,
- index_names,
- index_fields,
- value_col,
+ internal.spark_frame,
+ internal.index_spark_column_names,
+ internal.index_names,
+ internal.index_fields,
+ internal.data_spark_column_names[0],
)
# ============================================================================
diff --git a/python/tests/geopandas/test_geodataframe.py b/python/tests/geopandas/test_geodataframe.py
index e7c51eb7772..282199b905a 100644
--- a/python/tests/geopandas/test_geodataframe.py
+++ b/python/tests/geopandas/test_geodataframe.py
@@ -31,6 +31,7 @@ from shapely.geometry import (
import shapely
from sedona.spark.geopandas import GeoDataFrame, GeoSeries
+from sedona.spark.sql import st_functions as stf
from tests.geopandas.test_geopandas_base import TestGeopandasBase
import pyspark.pandas as ps
import pandas as pd
@@ -277,6 +278,136 @@ class TestGeoDataFrame(TestGeopandasBase):
with pytest.raises(NotImplementedError, match="byte_order"):
gdf.to_wkb(byte_order=0)
+ def test_explode_uses_native_plan_and_preserves_geometry_metadata(self):
+ source = GeoDataFrame(
+ {
+ "name": ["first", "second"],
+ "geometry": [
+ MultiPoint([(0, 0), (1, 1)]),
+ GeometryCollection([Point(2, 2), MultiPoint([(3, 3), (4, 4)])]),
+ ],
+ },
+ geometry="geometry",
+ crs="EPSG:3857",
+ )
+
+ result = source.explode(index_parts=True)
+
+ assert isinstance(result, GeoDataFrame)
+ assert result.active_geometry_name == "geometry"
+ assert result.crs.to_epsg() == 3857
+ assert result.index.names == [None, None]
+ assert result.to_geopandas()["name"].tolist() == [
+ "first",
+ "first",
+ "second",
+ "second",
+ ]
+
+ srids = result._internal.spark_frame.select(
+ stf.ST_SRID(result.geometry.spark.column).alias("srid")
+ ).collect()
+ assert {row.srid for row in srids} == {3857}
+
+ spark_frame = result._internal.spark_frame
+ if hasattr(spark_frame, "_jdf"):
+ plan = spark_frame._jdf.queryExecution().executedPlan().toString()
+ assert "Generate" in plan
+ assert "BatchEvalPython" not in plan
+ assert "ArrowEvalPython" not in plan
+ assert "Join" not in plan
+
+ def test_explode_all_rows_dropped_preserves_geometry_metadata(self):
+ source = GeoDataFrame(
+ {
+ "value": [1, 2, 3],
+ "shape": [MultiPoint(), GeometryCollection(), None],
+ },
+ geometry="shape",
+ crs="EPSG:4326",
+ )
+
+ result = source.explode(ignore_index=True)
+
+ assert len(result) == 0
+ assert result.active_geometry_name == "shape"
+ assert isinstance(result["shape"], GeoSeries)
+ assert result.crs.to_epsg() == 4326
+ collected = result.to_geopandas()
+ assert str(collected.dtypes["shape"]) == "geometry"
+ assert collected.crs.to_epsg() == 4326
+
+ def test_explode_non_geometry_column(self):
+ source_gpd = gpd.GeoDataFrame(
+ {
+ "values": [[1, 2], [], [3]],
+ "shape": [Point(0, 0), Point(1, 1), Point(2, 2)],
+ },
+ geometry="shape",
+ crs="EPSG:4326",
+ )
+ result = GeoDataFrame(source_gpd).explode("values", ignore_index=True)
+
+ assert isinstance(result, GeoDataFrame)
+ assert result.active_geometry_name == "shape"
+ assert result.crs.to_epsg() == 4326
+ assert_frame_equal(
+ result.to_geopandas(),
+ source_gpd.explode("values", ignore_index=True),
+ check_dtype=False,
+ )
+
+ def test_explode_geometry_column_uses_active_geometry(self):
+ source_gpd = gpd.GeoDataFrame(
+ {
+ "before": [1],
+ "geometry": [MultiPoint([(0, 0), (1, 1)])],
+ "other": gpd.GeoSeries([MultiPoint([(10, 10), (20, 20)])]),
+ "after": [2],
+ },
+ geometry="geometry",
+ crs="EPSG:4326",
+ )
+
+ expected = source_gpd.explode(column="other", index_parts=True)
+ result = GeoDataFrame(source_gpd).explode(column="other", index_parts=True)
+
+ from geopandas.testing import assert_geodataframe_equal
+
+ assert_geodataframe_equal(
+ result.to_geopandas(),
+ expected,
+ check_index_type=False,
+ )
+
+ def test_explode_internal_name_collisions(self):
+ source_gpd = gpd.GeoDataFrame(
+ {
+ "__frame_explode_index_0__": [1],
+ "__frame_explode_data_0__": [2],
+ "__frame_explode_parent_order__": [3],
+ "__frame_explode_position__": [4],
+ "__frame_explode_value__": [5],
+ "__frame_explode_sequence__": [6],
+ "geometry": [MultiPoint([(0, 0), (1, 1)])],
+ },
+ geometry="geometry",
+ )
+
+ result = GeoDataFrame(source_gpd).explode(index_parts=True).to_geopandas()
+ expected = source_gpd.explode(index_parts=True)
+
+ from geopandas.testing import assert_geodataframe_equal
+
+ assert_geodataframe_equal(result, expected, check_index_type=False)
+
+ def test_explode_docstring_examples_are_syntactically_valid(self):
+ import doctest
+
+ examples = doctest.DocTestParser().get_examples(GeoDataFrame.explode.__doc__)
+ for example in examples:
+ compile(example.source, "<GeoDataFrame.explode>", "single")
+
def test_getitem(self):
geoms = [Point(x, x) for x in range(3)]
ids = [1, 2, 3]
diff --git a/python/tests/geopandas/test_match_geopandas_dataframe.py b/python/tests/geopandas/test_match_geopandas_dataframe.py
index 5659ddf54c8..bbcdc4f0dee 100644
--- a/python/tests/geopandas/test_match_geopandas_dataframe.py
+++ b/python/tests/geopandas/test_match_geopandas_dataframe.py
@@ -130,6 +130,59 @@ class TestMatchGeopandasDataFrame(TestGeopandasBase):
self.check_sgpd_df_equals_gpd_df(sgpd_df, gpd_df)
+ @pytest.mark.parametrize(
+ "kwargs",
+ [
+ {},
+ {"index_parts": True},
+ {"ignore_index": True},
+ {"ignore_index": True, "index_parts": True},
+ ],
+ )
+ def test_explode(self, kwargs):
+ from geopandas.testing import assert_geodataframe_equal
+
+ index = pd.MultiIndex.from_tuples(
+ [
+ ("group-a", 1),
+ ("group-a", 1),
+ ("group-b", 2),
+ ("group-b", 3),
+ ("group-c", 4),
+ ],
+ names=["group", "feature_id"],
+ )
+ expected_source = gpd.GeoDataFrame(
+ {
+ "label": ["multi", "single-empty", "multi-empty", "null", "collection"],
+ "secondary": gpd.GeoSeries(
+ [Point(i, i) for i in range(5)],
+ index=index,
+ ),
+ "shape": [
+ MultiPoint([(0, 0), (1, 1)]),
+ Point(),
+ MultiPoint(),
+ None,
+ GeometryCollection([Point(2, 2), MultiPoint([(3, 3), (4, 4)])]),
+ ],
+ },
+ index=index,
+ geometry="shape",
+ crs="EPSG:4326",
+ )
+
+ expected = expected_source.explode(**kwargs)
+ actual = GeoDataFrame(expected_source).explode(**kwargs).to_geopandas()
+
+ assert_geodataframe_equal(
+ actual,
+ expected,
+ check_index_type=False,
+ check_geom_type=True,
+ )
+ pd.testing.assert_index_equal(actual.index, expected.index, exact=False)
+
def test_set_geometry(self):
sgpd_df = GeoDataFrame(self.geometries)
gpd_df = gpd.GeoDataFrame(self.geometries)