From dc05edb4d7368840667d0e872091a74d1716d409 Mon Sep 17 00:00:00 2001 From: Jacob Wasserman Date: Wed, 23 Jul 2014 00:57:36 -0400 Subject: [PATCH 1/2] Add explode() and collect() functions explode() expands multi-part geometries into multiple rows, and into their single part geometries. collect() and take multiple single geometries and combine them into their Multi* counterparts. --- geopandas/base.py | 43 +++++++++++++++++++++++++++++- geopandas/tools/__init__.py | 5 ++-- geopandas/tools/util.py | 52 +++++++++++++++++++++++++++++++++++++ tests/test_geom_methods.py | 19 ++++++++++++-- 4 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 geopandas/tools/util.py diff --git a/geopandas/base.py b/geopandas/base.py index 525598f..1e17dab 100644 --- a/geopandas/base.py +++ b/geopandas/base.py @@ -6,7 +6,7 @@ from shapely.ops import cascaded_union, unary_union import shapely.affinity as affinity import numpy as np -from pandas import Series, DataFrame +from pandas import Series, DataFrame, MultiIndex import geopandas as gpd @@ -411,6 +411,47 @@ class GeoPandasBase(object): use_radians=use_radians) for s in self.geometry], index=self.index, crs=self.crs) + def explode(self): + """ + Explode multi-part geometries into multiple single geometries. + + Single rows can become multiple rows. + This is analogous to PostGIS's ST_Dump(). The 'path' index is the + second level of the returned MultiIndex + + Returns + ------ + A GeoSeries with a MultiIndex. The levels of the MultiIndex are the + original index and an integer. + + Example + ------- + >>> gdf # gdf is GeoSeries of MultiPoints + 0 (POINT (0 0), POINT (1 1)) + 1 (POINT (2 2), POINT (3 3), POINT (4 4)) + + >>> gdf.explode() + 0 0 POINT (0 0) + 1 POINT (1 1) + 1 0 POINT (2 2) + 1 POINT (3 3) + 2 POINT (4 4) + dtype: object + + """ + index = [] + geometries = [] + for idx, s in self.geometry.iteritems(): + if s.type.startswith('Multi') or s.type == 'GeometryCollection': + geoms = s.geoms + idxs = [(idx, i) for i in range(len(geoms))] + else: + geoms = [s] + idxs = [(idx, 0)] + index.extend(idxs) + geometries.extend(geoms) + return gpd.GeoSeries(geometries, + index=MultiIndex.from_tuples(index)).__finalize__(self) def _array_input(arr): if isinstance(arr, (MultiPoint, MultiLineString, MultiPolygon)): diff --git a/geopandas/tools/__init__.py b/geopandas/tools/__init__.py index 3f9632c..daabcad 100644 --- a/geopandas/tools/__init__.py +++ b/geopandas/tools/__init__.py @@ -3,11 +3,12 @@ from __future__ import absolute_import from .geocoding import geocode, reverse_geocode from .overlay import overlay from .sjoin import sjoin +from .util import collect __all__ = [ 'overlay', 'sjoin', 'geocode', - 'reverse_geocode' + 'reverse_geocode', + 'collect', ] - diff --git a/geopandas/tools/util.py b/geopandas/tools/util.py new file mode 100644 index 0000000..c057eba --- /dev/null +++ b/geopandas/tools/util.py @@ -0,0 +1,52 @@ +import pandas as pd +import geopandas as gpd +from shapely.geometry import ( + Point, + LineString, + Polygon, + MultiPoint, + MultiLineString, + MultiPolygon +) +from shapely.geometry.base import BaseGeometry + +_multi_type_map = { + 'Point': MultiPoint, + 'LineString': MultiLineString, + 'Polygon': MultiPolygon +} + +def collect(x, multi=False): + """ + Collect single part geometries into their Multi* counterpart + + Parameters + ---------- + x : an iterable or Series of Shapely geometries, a GeoSeries, or + a single Shapely geometry + multi : boolean, default False + if True, force returned geometries to be Multi* even if they + only have one component. + + """ + if isinstance(x, BaseGeometry): + x = [x] + elif isinstance(x, pd.Series): + x = list(x) + + # We cannot create GeometryCollection here so all types + # must be the same. If there is more than one element, + # they cannot be Multi*, i.e., can't pass in combination of + # Point and MultiPoint... or even just MultiPoint + t = x[0].type + if not all(g.type == t for g in x): + raise ValueError('Geometry type must be homogenous') + if len(x) > 1 and t.startswith('Multi'): + raise ValueError( + 'Cannot collect {0}. Must have single geometries'.format(t)) + + if len(x) == 1 and (t.startswith('Multi') or not multi): + # If there's only one single part geom and we're not forcing to + # multi, then just return it + return x[0] + return _multi_type_map[t](x) diff --git a/tests/test_geom_methods.py b/tests/test_geom_methods.py index 9e87965..8c451d8 100644 --- a/tests/test_geom_methods.py +++ b/tests/test_geom_methods.py @@ -5,8 +5,10 @@ import string import numpy as np from numpy.testing import assert_array_equal from pandas.util.testing import assert_series_equal, assert_frame_equal -from pandas import Series, DataFrame -from shapely.geometry import Point, LinearRing, LineString, Polygon +from pandas import Series, DataFrame, MultiIndex +from shapely.geometry import ( + Point, LinearRing, LineString, Polygon, MultiPoint +) from shapely.geometry.collection import GeometryCollection from geopandas import GeoSeries, GeoDataFrame @@ -389,6 +391,19 @@ class TestGeomMethods(unittest.TestCase): 'col1': range(len(self.landmarks))}) self.assert_(df.total_bounds, bbox) + def test_explode(self): + s = GeoSeries([MultiPoint([(0,0), (1,1)]), + MultiPoint([(2,2), (3,3), (4,4)])]) + + index = [(0, 0), (0, 1), (1, 0), (1, 1), (1, 2)] + expected = GeoSeries([Point(0,0), Point(1,1), Point(2,2), Point(3,3), + Point(4,4)], index=MultiIndex.from_tuples(index)) + + assert_geoseries_equal(expected, s.explode()) + + df = self.gdf1[:2].set_geometry(s) + assert_geoseries_equal(expected, df.explode()) + # # Test '&', '|', '^', and '-' # The left can only be a GeoSeries. The right hand side can be a From fb57a6c15aefc84b8e23c7e47225d862ee7afdca Mon Sep 17 00:00:00 2001 From: Jacob Wasserman Date: Wed, 23 Jul 2014 08:42:10 -0400 Subject: [PATCH 2/2] Add tests for collect() --- tests/test_tools.py | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/test_tools.py diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..fbe7759 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,49 @@ +from __future__ import absolute_import +from shapely.geometry import Point, MultiPoint, LineString +from geopandas import GeoSeries +from geopandas.tools import collect +from .util import unittest + +class TestTools(unittest.TestCase): + def setUp(self): + self.p1 = Point(0,0) + self.p2 = Point(1,1) + self.p3 = Point(2,2) + self.mpc = MultiPoint([self.p1, self.p2, self.p3]) + + self.mp1 = MultiPoint([self.p1, self.p2]) + self.line1 = LineString([(3,3), (4,4)]) + + def test_collect_single(self): + result = collect(self.p1) + self.assert_(self.p1.equals(result)) + + def test_collect_single_force_multi(self): + result = collect(self.p1, multi=True) + expected = MultiPoint([self.p1]) + self.assert_(expected.equals(result)) + + def test_collect_multi(self): + result = collect(self.mp1) + self.assert_(self.mp1.equals(result)) + + def test_collect_multi_force_multi(self): + result = collect(self.mp1) + self.assert_(self.mp1.equals(result)) + + def test_collect_list(self): + result = collect([self.p1, self.p2, self.p3]) + self.assert_(self.mpc.equals(result)) + + def test_collect_GeoSeries(self): + s = GeoSeries([self.p1, self.p2, self.p3]) + result = collect(s) + self.assert_(self.mpc.equals(result)) + + def test_collect_mixed_types(self): + with self.assertRaises(ValueError): + collect([self.p1, self.line1]) + + def test_collect_mixed_multi(self): + with self.assertRaises(ValueError): + collect([self.mpc, self.mp1])