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:40 UTC
(sedona) branch feature/geopandas-frame-explode created (now a64b0779a1a)
This is an automated email from the ASF dual-hosted git repository.
jiayuasu pushed a change to branch feature/geopandas-frame-explode
in repository https://gitbox.apache.org/repos/asf/sedona.git
at a64b0779a1a [GH-3243] GeoPandas: Implement GeoDataFrame explode
This branch includes the following new commits:
new a64b0779a1a [GH-3243] GeoPandas: Implement GeoDataFrame explode
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(sedona) 01/01: [GH-3243] GeoPandas: Implement GeoDataFrame explode
Posted by ji...@apache.org.
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)