Repository: geopandas/geopandas

Issue:
CI: 310-dev.yaml doesn't use numpy main wheel
In having a look at a CI failure I noticed that the dev environment doesn't actually use numpy main, although the yaml suggests it does.

My assumption is we intend to use numpy main and's not working, but if that's wrong, we could just update the yaml for clarity.
Looking the ci for the last commit in geopandas/main for the 310-dev environment (https://github.com/geopandas/geopandas/runs/4879861839?check_suite_focus=true), in the check and log environment section we have
```
PYTHON DEPENDENCIES
-------------------
geopandas  : 0+untagged.1.gcebb587
pandas     : 1.5.0.dev0+128.g91d3f0b246
fiona      : 1.8.20
numpy      : 1.22.0
```
but in the corresponding yaml is:
https://github.com/geopandas/geopandas/blob/cebb5873d04a81bf37376dfb5d4b2adcfe9d87af/ci/envs/310-dev.yaml#L25-L32

So we should have a dev version of numpy installed.

https://github.com/geopandas/geopandas/commit/4361c30f474df0751a7006e0d91f3fc8376b723c switched these installs from using git+... to installing from anaconda nightly wheels. In CI run for that (https://github.com/geopandas/geopandas/runs/4341268848?check_suite_focus=true), dev pandas is installed properly but numpy isn't (it's just numpy 1.21.4).

In this run, both numpy and shapely are installed by conda (shapely as dependency for fiona, and numpy as dependency for shapely I guess?). Looking further at the logs, shapely master is cloned but not installed (commit 5ca00a40a48b1813125b2f3e7b8dbcd5087b3078). Also, scipy nightly is installed from the anaconda wheels, which might not be intended - based on this: `Looking in indexes: https://pypi.org/simple, https://pypi.anaconda.org/scipy-wheels-nightly/simple, https://pypi.anaconda.org/scipy-wheels-nightly/simple` extra index seems to get applied globally to the entire pip section in the yaml, not a particular line.

In the commit following the numpy/ pandas wheel change (https://github.com/geopandas/geopandas/commit/791b21bc4ad2eef225acc9e99d6340957df04079) shapely main is actually installed, again shapely 1.8 is installed by conda, but is subsequently uninstalled an replaced by shapely-2.0.0dev. Not sure why this doesn't happen for numpy as well 






Discussion:
jorisvandenbossche: OK, that doesn't seem to work, getting this error:

```
Installing pip dependencies: ...working... ::warning::Pip subprocess error:%0AUsage: __main__.py [options]%0A%0AERROR: Invalid requirement: --upgrade --pre --extra-index https://pypi.anaconda.org/scipy-wheels-nightly/simple numpy%0A__main__.py: error: no such option: --upgrade%0A%0A%0A
  Pip subprocess error:
  Usage: __main__.py [options]
  
  ERROR: Invalid requirement: --upgrade --pre --extra-index https://pypi.anaconda.org/scipy-wheels-nightly/simple numpy
  __main__.py: error: no such option: --upgrade
  
  
  Ran pip subprocess with arguments:
  ['/usr/share/miniconda3/envs/test/bin/python', '-m', 'pip', 'install', '-U', '-r', '/home/runner/work/geopandas/geopandas/ci/envs/condaenv.s9btt1el.requirements.txt']
  Pip subprocess output:
  
  failed
```
I don't understand why `--upgrade` isn't a valid option, but from looking at the output, it also seems that the actual `pip install` call is already using `-U`. 

Patch:
diff --git a/ci/envs/310-dev.yaml b/ci/envs/310-dev.yaml
index 52575dbd..4274d679 100644
--- a/ci/envs/310-dev.yaml
+++ b/ci/envs/310-dev.yaml
@@ -26,8 +26,9 @@ dependencies:
     - geopy
     - mapclassify>=2.4.0
     # dev versions of packages
-    - --pre --extra-index https://pypi.anaconda.org/scipy-wheels-nightly/simple numpy
-    - --pre --extra-index https://pypi.anaconda.org/scipy-wheels-nightly/simple pandas
+    - --pre --extra-index https://pypi.anaconda.org/scipy-wheels-nightly/simple
+    - numpy
+    - pandas
     - git+https://github.com/matplotlib/matplotlib.git@main
     # - git+https://github.com/Toblerity/Shapely.git@main
     - git+https://github.com/pygeos/pygeos.git@master
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index f39f8754..788e6274 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1561,6 +1561,7 @@ individually so that features may have different properties
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
         aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc)
+        aggregated_data.columns = aggregated_data.columns.to_flat_index()
 
         # Process spatial component
         def merge_geometries(block):
@@ -1683,43 +1684,26 @@ individually so that features may have different properties
                 )
             index_parts = True
 
-        df_copy = self.copy()
+        exploded_geom = self.geometry.reset_index(drop=True).explode(index_parts=True)
 
-        level_str = f"level_{df_copy.index.nlevels}"
-
-        if level_str in df_copy.columns:  # GH1393
-            df_copy = df_copy.rename(columns={level_str: f"__{level_str}"})
-
-        if index_parts:
-            exploded_geom = df_copy.geometry.explode(index_parts=True)
-            exploded_index = exploded_geom.index
-            exploded_geom = exploded_geom.reset_index(level=-1, drop=True)
-        else:
-            exploded_geom = df_copy.geometry.explode(index_parts=True).reset_index(
-                level=-1, drop=True
-            )
-            exploded_index = exploded_geom.index
-
-        df = (
-            df_copy.drop(df_copy._geometry_column_name, axis=1)
-            .join(exploded_geom)
-            .set_geometry(self._geometry_column_name)
-            .__finalize__(self)
-        )
+        df = GeoDataFrame(
+            self.drop(self._geometry_column_name, axis=1).take(
+                exploded_geom.index.droplevel(-1)
+            ),
+            geometry=exploded_geom.values,
+        ).__finalize__(self)
 
         if ignore_index:
             df.reset_index(inplace=True, drop=True)
         elif index_parts:
             # reset to MultiIndex, otherwise df index is only first level of
             # exploded GeoSeries index.
-            df.set_index(exploded_index, inplace=True)
-            df.index.names = list(self.index.names) + [None]
-        else:
-            df.set_index(exploded_index, inplace=True)
-            df.index.names = self.index.names
-
-        if f"__{level_str}" in df.columns:
-            df = df.rename(columns={f"__{level_str}": level_str})
+            df = df.set_index(
+                exploded_geom.index.droplevel(
+                    list(range(exploded_geom.index.nlevels - 1))
+                ),
+                append=True,
+            )
 
         return df
 


Test patch:
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
index 42023cdc..2724b24b 100644
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -10,6 +10,7 @@ import shapely.affinity
 import shapely.geometry
 from shapely.geometry.base import CAP_STYLE, JOIN_STYLE
 import shapely.wkb
+import shapely.wkt
 from shapely._buildcfg import geos_version
 
 import geopandas
@@ -32,7 +33,7 @@ triangle_no_missing = [
     shapely.geometry.Polygon([(random.random(), random.random()) for i in range(3)])
     for _ in range(10)
 ]
-triangles = triangle_no_missing + [shapely.geometry.Polygon(), None]
+triangles = triangle_no_missing + [shapely.wkt.loads("POLYGON EMPTY"), None]
 T = from_shapely(triangles)
 
 points_no_missing = [
@@ -356,31 +357,8 @@ def test_predicates_vector_vector(attr, args):
 def test_unary_geo(attr):
     na_value = None
 
-    if attr == "boundary":
-        # pygeos returns None for empty geometries
-        if not compat.USE_PYGEOS:
-            # boundary raises for empty geometry
-            with pytest.raises(Exception):
-                T.boundary
-
-        values = triangle_no_missing + [None]
-        A = from_shapely(values)
-    else:
-        values = triangles
-        A = T
-
-    result = getattr(A, attr)
-    if attr == "exterior" and compat.USE_PYGEOS:
-        # TODO(pygeos)
-        # empty Polygon() has an exterior with shapely > 1.7, which gives
-        # empty LinearRing instead of None,
-        # but conversion to pygeos still results in empty GeometryCollection
-        expected = [
-            getattr(t, attr) if t is not None and not t.is_empty else na_value
-            for t in values
-        ]
-    else:
-        expected = [getattr(t, attr) if t is not None else na_value for t in values]
+    result = getattr(T, attr)
+    expected = [getattr(t, attr) if t is not None else na_value for t in triangles]
 
     assert equal_geometries(result, expected)
 
@@ -455,7 +433,14 @@ def test_binary_geo_scalar(attr):
         "has_z",
         # for is_ring we raise a warning about the value for Polygon changing
         pytest.param(
-            "is_ring", marks=pytest.mark.filterwarnings("ignore:is_ring:FutureWarning")
+            "is_ring",
+            marks=[
+                pytest.mark.filterwarnings("ignore:is_ring:FutureWarning"),
+                pytest.mark.skipif(
+                    not compat.SHAPELY_GE_17,
+                    reason="is_ring on empty Polygon doesn't work in Shapely 1.6",
+                ),
+            ],
         ),
     ],
 )
@@ -473,11 +458,9 @@ def test_unary_predicates(attr):
 
     result = getattr(V, attr)
 
-    if attr == "is_simple" and (geos_version < (3, 8) or compat.USE_PYGEOS):
+    if attr == "is_simple" and geos_version < (3, 8):
         # poly.is_simple raises an error for empty polygon for GEOS < 3.8
         # with shapely, pygeos always returns False for all GEOS versions
-        # But even for Shapely with GEOS >= 3.8, empty GeometryCollection
-        # returns True instead of False
         expected = [
             getattr(t, attr) if t is not None and not t.is_empty else na_value
             for t in vals
@@ -489,6 +472,9 @@ def test_unary_predicates(attr):
             else na_value
             for t in vals
         ]
+        # empty Linearring.is_ring gives False with Shapely < 2.0
+        if compat.USE_PYGEOS and not compat.SHAPELY_GE_20:
+            expected[-2] = True
     else:
         expected = [getattr(t, attr) if t is not None else na_value for t in vals]
     assert result.tolist() == expected
@@ -496,16 +482,23 @@ def test_unary_predicates(attr):
 
 # for is_ring we raise a warning about the value for Polygon changing
 @pytest.mark.filterwarnings("ignore:is_ring:FutureWarning")
+@pytest.mark.skipif(
+    not compat.SHAPELY_GE_17,
+    reason="is_ring on empty Polygon doesn't work in Shapely 1.6",
+)
 def test_is_ring():
     g = [
         shapely.geometry.LinearRing([(0, 0), (1, 1), (1, -1)]),
         shapely.geometry.LineString([(0, 0), (1, 1), (1, -1)]),
         shapely.geometry.LineString([(0, 0), (1, 1), (1, -1), (0, 0)]),
         shapely.geometry.Polygon([(0, 0), (1, 1), (1, -1)]),
-        shapely.geometry.Polygon(),
+        shapely.wkt.loads("POLYGON EMPTY"),
         None,
     ]
-    expected = [True, False, True, True, False, False]
+    expected = [True, False, True, True, True, False]
+    if not compat.USE_PYGEOS and not compat.SHAPELY_GE_20:
+        # empty polygon is_ring gives False with Shapely < 2.0
+        expected[-2] = False
 
     result = from_shapely(g).is_ring
 
@@ -525,7 +518,7 @@ def test_unary_float(attr):
 def test_geom_types():
     cat = T.geom_type
     # empty polygon has GeometryCollection type
-    assert list(cat) == ["Polygon"] * (len(T) - 2) + ["GeometryCollection", None]
+    assert list(cat) == ["Polygon"] * (len(T) - 1) + [None]
 
 
 def test_geom_types_null_mixed():
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index 20e628f4..2d887614 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -8,6 +8,8 @@ from geopandas import _compat as compat
 from pandas.testing import assert_frame_equal
 import pytest
 
+from geopandas.testing import assert_geodataframe_equal
+
 
 @pytest.fixture
 def nybb_polydf():
@@ -71,7 +73,7 @@ def test_dissolve_retains_nonexisting_crs(nybb_polydf):
     assert test.crs is None
 
 
-def first_dissolve(nybb_polydf, first):
+def test_first_dissolve(nybb_polydf, first):
     test = nybb_polydf.dissolve("manhattan_bronx")
     assert_frame_equal(first, test, check_column_type=False)
 
@@ -297,3 +299,21 @@ def test_dissolve_dropna_warn(nybb_polydf):
         UserWarning, match="dropna kwarg is not supported for pandas < 1.1.0"
     ):
         nybb_polydf.dissolve(dropna=False)
+
+
+def test_dissolve_multi_agg(nybb_polydf, merged_shapes):
+
+    merged_shapes[("BoroCode", "min")] = [3, 1]
+    merged_shapes[("BoroCode", "max")] = [5, 2]
+    merged_shapes[("BoroName", "count")] = [3, 2]
+
+    with pytest.warns(None) as record:
+        test = nybb_polydf.dissolve(
+            by="manhattan_bronx",
+            aggfunc={
+                "BoroCode": ["min", "max"],
+                "BoroName": "count",
+            },
+        )
+    assert_geodataframe_equal(test, merged_shapes)
+    assert len(record) == 0
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 221eca38..941b70fd 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -1090,6 +1090,109 @@ class TestGeomMethods:
         test_df = df.explode(ignore_index=True, index_parts=True)
         assert_frame_equal(test_df, expected_df)
 
+    def test_explode_order(self):
+        df = GeoDataFrame(
+            {"vals": [1, 2, 3]},
+            geometry=[MultiPoint([(x, x), (x, 0)]) for x in range(3)],
+            index=[2, 9, 7],
+        )
+        test_df = df.explode(index_parts=True)
+
+        expected_index = MultiIndex.from_arrays(
+            [[2, 2, 9, 9, 7, 7], [0, 1, 0, 1, 0, 1]],
+        )
+        expected_geometry = GeoSeries(
+            [
+                Point(0, 0),
+                Point(0, 0),
+                Point(1, 1),
+                Point(1, 0),
+                Point(2, 2),
+                Point(2, 0),
+            ],
+            index=expected_index,
+        )
+        expected_df = GeoDataFrame(
+            {"vals": [1, 1, 2, 2, 3, 3]},
+            geometry=expected_geometry,
+            index=expected_index,
+        )
+        assert_geodataframe_equal(test_df, expected_df)
+
+    def test_explode_order_no_multi(self):
+        df = GeoDataFrame(
+            {"vals": [1, 2, 3]},
+            geometry=[Point(0, x) for x in range(3)],
+            index=[2, 9, 7],
+        )
+        test_df = df.explode(index_parts=True)
+
+        expected_index = MultiIndex.from_arrays(
+            [[2, 9, 7], [0, 0, 0]],
+        )
+        expected_df = GeoDataFrame(
+            {"vals": [1, 2, 3]},
+            geometry=[Point(0, x) for x in range(3)],
+            index=expected_index,
+        )
+        assert_geodataframe_equal(test_df, expected_df)
+
+    def test_explode_order_mixed(self):
+        df = GeoDataFrame(
+            {"vals": [1, 2, 3]},
+            geometry=[MultiPoint([(x, x), (x, 0)]) for x in range(2)] + [Point(0, 10)],
+            index=[2, 9, 7],
+        )
+        test_df = df.explode(index_parts=True)
+
+        expected_index = MultiIndex.from_arrays(
+            [[2, 2, 9, 9, 7], [0, 1, 0, 1, 0]],
+        )
+        expected_geometry = GeoSeries(
+            [
+                Point(0, 0),
+                Point(0, 0),
+                Point(1, 1),
+                Point(1, 0),
+                Point(0, 10),
+            ],
+            index=expected_index,
+        )
+        expected_df = GeoDataFrame(
+            {"vals": [1, 1, 2, 2, 3]},
+            geometry=expected_geometry,
+            index=expected_index,
+        )
+        assert_geodataframe_equal(test_df, expected_df)
+
+    def test_explode_duplicated_index(self):
+        df = GeoDataFrame(
+            {"vals": [1, 2, 3]},
+            geometry=[MultiPoint([(x, x), (x, 0)]) for x in range(3)],
+            index=[1, 1, 2],
+        )
+        test_df = df.explode(index_parts=True)
+        expected_index = MultiIndex.from_arrays(
+            [[1, 1, 1, 1, 2, 2], [0, 1, 0, 1, 0, 1]],
+        )
+        expected_geometry = GeoSeries(
+            [
+                Point(0, 0),
+                Point(0, 0),
+                Point(1, 1),
+                Point(1, 0),
+                Point(2, 2),
+                Point(2, 0),
+            ],
+            index=expected_index,
+        )
+        expected_df = GeoDataFrame(
+            {"vals": [1, 1, 2, 2, 3, 3]},
+            geometry=expected_geometry,
+            index=expected_index,
+        )
+        assert_geodataframe_equal(test_df, expected_df)
+
     #
     # Test '&', '|', '^', and '-'
     #
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 72dbcffc..42c79de7 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -558,6 +558,7 @@ def test_groupby_groups(df):
     assert_frame_equal(res, exp)
 
 
+@pytest.mark.skip_no_sindex
 @pytest.mark.skipif(
     compat.PANDAS_GE_13 and not compat.PANDAS_GE_14,
     reason="this was broken in pandas 1.3.5 (GH-2294)",